├── .env.example
├── .eslintrc.js
├── .github
└── workflows
│ └── codeql-analysis.yml
├── .gitignore
├── .prettierrc.js
├── README.md
├── cache.config.js
├── jsconfig.json
├── next.config.js
├── package.json
├── postcss.config.js
├── public
├── .well-known
│ └── assetlinks.json
├── favicon.ico
├── fonts
│ └── Verveine
│ │ ├── Verveine.eot
│ │ ├── Verveine.svg
│ │ ├── Verveine.ttf
│ │ ├── Verveine.woff
│ │ └── Verveine.woff2
├── img
│ ├── articles
│ │ ├── progress-update-1
│ │ │ └── card.jpg
│ │ └── progress-update-2
│ │ │ └── card.jpg
│ ├── avatar.jpg
│ ├── bg-dark.png
│ ├── card.jpg
│ └── icons
│ │ ├── android-chrome-192x192.png
│ │ ├── android-chrome-512x512.png
│ │ ├── apple-touch-icon.png
│ │ ├── browserconfig.xml
│ │ ├── favicon-16x16.png
│ │ ├── favicon-32x32.png
│ │ ├── mstile-150x150.png
│ │ ├── safari-pinned-tab.svg
│ │ └── site.webmanifest
└── robots.txt
├── src
├── articles
│ ├── progress-update-1.mdx
│ └── progress-update-2.mdx
├── components
│ ├── App
│ │ ├── Alert.js
│ │ ├── AlertManager.js
│ │ ├── Avatar.js
│ │ ├── ClientOnly.js
│ │ ├── Compose.js
│ │ ├── Icon.js
│ │ ├── ImageGrid.js
│ │ ├── LoadLink.js
│ │ ├── LoadingButton.js
│ │ ├── Modal.js
│ │ ├── Navigation
│ │ │ ├── BottomNav.js
│ │ │ ├── SideNav.js
│ │ │ └── TopNav.js
│ │ ├── Notification.js
│ │ ├── PageLayout.js
│ │ ├── Post.js
│ │ ├── Skeleton.js
│ │ └── Tabs.js
│ ├── Global
│ │ ├── BaseLayout.js
│ │ ├── Head.js
│ │ ├── Logo.js
│ │ ├── ThemeManager.js
│ │ └── Transition.js
│ └── Marketing
│ │ ├── ConfirmationModal.js
│ │ ├── EarlyAccessForm.js
│ │ └── Video.js
├── context
│ ├── alerts.js
│ └── theme.js
├── hooks
│ ├── alert.js
│ ├── click-outside.js
│ ├── format.js
│ ├── meta.js
│ ├── notifications.js
│ ├── sticky.js
│ ├── tailwind.js
│ ├── theme.js
│ └── user.js
├── middleware
│ └── auth.js
├── pages
│ ├── 404.js
│ ├── [profile]
│ │ ├── index.js
│ │ ├── posts
│ │ │ └── [post].js
│ │ └── replies.js
│ ├── _app.js
│ ├── _document.js
│ ├── _error.js
│ ├── api
│ │ ├── auth
│ │ │ ├── login.js
│ │ │ └── register.js
│ │ └── meta
│ │ │ ├── post.js
│ │ │ └── profile.js
│ ├── blog
│ │ └── [slug].js
│ ├── code-of-conduct.mdx
│ ├── home.js
│ ├── index.js
│ ├── login.js
│ ├── meta
│ │ ├── post.js
│ │ └── profile.js
│ ├── notifications.js
│ ├── onboarding
│ │ ├── identity.js
│ │ ├── profile.js
│ │ ├── subscription.js
│ │ └── verify.js
│ ├── register.js
│ ├── search.js
│ └── settings.js
├── scss
│ └── app.scss
└── utils
│ ├── Client.js
│ ├── arr.js
│ ├── auth.js
│ ├── constants.js
│ ├── encoding.js
│ ├── errors.js
│ ├── puppeteer.js
│ └── redirectTo.js
├── tailwind.config.js
└── yarn.lock
/.env.example:
--------------------------------------------------------------------------------
1 | AURALITE_ID=
2 | AURALITE_SECRET=
3 | NEXT_PUBLIC_AURALITE_URL=
4 | NEXT_PUBLIC_STRIPE_KEY=
5 | VERCEL_REGION=dev1
6 | VERCEL_URL="http://localhost:3000"
7 |
8 | NODE_TLS_REJECT_UNAUTHORIZED=0
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | parser: 'babel-eslint',
4 | env: {
5 | node: true,
6 | browser: true,
7 | es6: true,
8 | commonjs: true,
9 | },
10 | extends: ['eslint:recommended', 'plugin:react/recommended', 'plugin:jsx-a11y/recommended', 'plugin:prettier/recommended'],
11 | parserOptions: {
12 | ecmaFeatures: {
13 | jsx: true,
14 | },
15 | ecmaVersion: 2020,
16 | },
17 | plugins: ['react'],
18 | rules: {
19 | 'import/prefer-default-export': 0,
20 | 'no-console': 'warn',
21 | 'no-nested-ternary': 0,
22 | 'no-underscore-dangle': 0,
23 | 'no-unused-expressions': ['error', { allowTernary: true }],
24 | camelcase: 0,
25 | 'react/self-closing-comp': 1,
26 | 'react/jsx-filename-extension': [1, { extensions: ['.js', 'jsx'] }],
27 | 'react/prop-types': 0,
28 | 'react/destructuring-assignment': 0,
29 | 'react/jsx-no-comment-textnodes': 0,
30 | 'react/jsx-props-no-spreading': 0,
31 | 'react/no-array-index-key': 0,
32 | 'react/no-unescaped-entities': 0,
33 | 'react/require-default-props': 0,
34 | 'jsx-a11y/label-has-for': 0,
35 | 'jsx-a11y/anchor-is-valid': 0,
36 | 'react/react-in-jsx-scope': 0,
37 | 'linebreak-style': ['error', 'unix'],
38 | semi: ['error', 'never'],
39 | 'prettier/prettier': ['error', {}, { usePrettierrc: true }],
40 | },
41 | }
42 |
--------------------------------------------------------------------------------
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | name: "CodeQL"
2 |
3 | on:
4 | push:
5 | branches: [master]
6 | pull_request:
7 | branches: [master]
8 | schedule:
9 | - cron: '0 8 * * 2'
10 |
11 | jobs:
12 | analyze:
13 | name: Analyze
14 | runs-on: ubuntu-latest
15 |
16 | strategy:
17 | fail-fast: false
18 | matrix:
19 | language: ['javascript']
20 |
21 | steps:
22 | - name: Checkout repository
23 | uses: actions/checkout@v2
24 | with:
25 | fetch-depth: 2
26 |
27 | - run: git checkout HEAD^2
28 | if: ${{ github.event_name == 'pull_request' }}
29 |
30 | - name: Initialize CodeQL
31 | uses: github/codeql-action/init@v1
32 | with:
33 | languages: ${{ matrix.language }}
34 |
35 | - name: Perform CodeQL Analysis
36 | uses: github/codeql-action/analyze@v1
37 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 |
21 | # debug
22 | npm-debug.log*
23 | yarn-debug.log*
24 | yarn-error.log*
25 |
26 | # local env files
27 | .env.local
28 | .env.development.local
29 | .env.test.local
30 | .env.production.local
31 |
32 | # editors
33 | .vscode
34 | .vercel
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | semi: false,
3 | singleQuote: true,
4 | printWidth: 1000,
5 | tabWidth: 4,
6 | trailingComma: "es5",
7 | useTabs: true,
8 | bracketSpacing: true,
9 | }
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # (WIP) Auralite Web
--------------------------------------------------------------------------------
/cache.config.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | module.exports = [
4 | {
5 | urlPattern: '/',
6 | handler: 'NetworkFirst',
7 | options: {
8 | cacheName: 'start-url',
9 | expiration: {
10 | maxEntries: 1,
11 | maxAgeSeconds: 24 * 60 * 60, // 24 hours
12 | },
13 | },
14 | },
15 | {
16 | urlPattern: /^https:\/\/fonts\.(?:googleapis|gstatic)\.com\/.*/i,
17 | handler: 'CacheFirst',
18 | options: {
19 | cacheName: 'google-fonts',
20 | expiration: {
21 | maxEntries: 4,
22 | maxAgeSeconds: 365 * 24 * 60 * 60, // 365 days
23 | },
24 | },
25 | },
26 | {
27 | urlPattern: /\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,
28 | handler: 'StaleWhileRevalidate',
29 | options: {
30 | cacheName: 'static-font-assets',
31 | expiration: {
32 | maxEntries: 4,
33 | maxAgeSeconds: 7 * 24 * 60 * 60, // 7 days
34 | },
35 | },
36 | },
37 | {
38 | urlPattern: /\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,
39 | handler: 'StaleWhileRevalidate',
40 | options: {
41 | cacheName: 'static-image-assets',
42 | expiration: {
43 | maxEntries: 64,
44 | maxAgeSeconds: 24 * 60 * 60, // 24 hours
45 | },
46 | },
47 | },
48 | {
49 | urlPattern: /\.(?:js)$/i,
50 | handler: 'StaleWhileRevalidate',
51 | options: {
52 | cacheName: 'static-js-assets',
53 | expiration: {
54 | maxEntries: 16,
55 | maxAgeSeconds: 24 * 60 * 60, // 24 hours
56 | },
57 | },
58 | },
59 | {
60 | urlPattern: /\.(?:css|less)$/i,
61 | handler: 'StaleWhileRevalidate',
62 | options: {
63 | cacheName: 'static-style-assets',
64 | expiration: {
65 | maxEntries: 16,
66 | maxAgeSeconds: 24 * 60 * 60, // 24 hours
67 | },
68 | },
69 | },
70 | {
71 | urlPattern: /\.(?:json|xml|csv)$/i,
72 | handler: 'StaleWhileRevalidate',
73 | options: {
74 | cacheName: 'static-data-assets',
75 | expiration: {
76 | maxEntries: 16,
77 | maxAgeSeconds: 24 * 60 * 60, // 24 hours
78 | },
79 | },
80 | },
81 | {
82 | urlPattern: /\/api\/.*$/i,
83 | handler: 'NetworkFirst',
84 | method: 'GET',
85 | options: {
86 | cacheName: 'apis',
87 | expiration: {
88 | maxEntries: 16,
89 | maxAgeSeconds: 24 * 60 * 60, // 24 hours
90 | },
91 | networkTimeoutSeconds: 10, // fall back to cache if api does not response within 10 seconds
92 | },
93 | },
94 | {
95 | urlPattern: /^https:\/\/ik\.imagekit\.io\/.*/i,
96 | handler: 'StaleWhileRevalidate',
97 | method: 'GET',
98 | options: {
99 | cacheName: 'auralite-image-cdn',
100 | expiration: {
101 | maxEntries: 64,
102 | maxAgeSeconds: 24 * 60 * 60, // 24 hours
103 | },
104 | },
105 | },
106 | {
107 | urlPattern: /^https:\/\/api\.auralite\.io\/api\/.*/i,
108 | handler: 'NetworkFirst',
109 | method: 'GET',
110 | options: {
111 | cacheName: 'apis',
112 | expiration: {
113 | maxEntries: 16,
114 | maxAgeSeconds: 24 * 60 * 60, // 24 hours
115 | },
116 | networkTimeoutSeconds: 10, // fall back to cache if api does not response within 10 seconds
117 | },
118 | },
119 | {
120 | urlPattern: /\/api\/.*$/i,
121 | handler: 'NetworkFirst',
122 | method: 'POST',
123 | options: {
124 | cacheName: 'apis',
125 | expiration: {
126 | maxEntries: 16,
127 | maxAgeSeconds: 24 * 60 * 60, // 24 hours
128 | },
129 | networkTimeoutSeconds: 10, // fall back to cache if api does not response within 10 seconds
130 | },
131 | },
132 | {
133 | urlPattern: /.*/i,
134 | handler: 'NetworkFirst',
135 | options: {
136 | cacheName: 'others',
137 | expiration: {
138 | maxEntries: 32,
139 | maxAgeSeconds: 24 * 60 * 60, // 24 hours
140 | },
141 | networkTimeoutSeconds: 10,
142 | },
143 | },
144 | ]
145 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "node_modules",
4 | "paths": {
5 | "@/*": [
6 | "../src/*"
7 | ]
8 | }
9 | }
10 | }
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | const withPWA = require('next-pwa')
2 | const withSourceMaps = require('@zeit/next-source-maps')()
3 | const SentryWebpackPlugin = require('@sentry/webpack-plugin')
4 |
5 | const { NEXT_PUBLIC_SENTRY_DSN: SENTRY_DSN, SENTRY_ORG, SENTRY_PROJECT, SENTRY_AUTH_TOKEN, NODE_ENV } = process.env
6 |
7 | process.env.SENTRY_DSN = SENTRY_DSN
8 |
9 | module.exports = withPWA(
10 | withSourceMaps({
11 | env: {
12 | commitHash: process.env.VERCEL_GITHUB_COMMIT_SHA,
13 | },
14 | experimental: {
15 | modern: true,
16 | optimizeFonts: true,
17 | optimizeImages: true,
18 | },
19 | poweredByHeader: false,
20 | pwa: {
21 | disable: process.env.NODE_ENV === 'development',
22 | register: true,
23 | runtimeCaching: require('./cache.config'),
24 | dest: 'public',
25 | },
26 | webpack: (config, options) => {
27 | if (!options.isServer) {
28 | config.resolve.alias['@sentry/node'] = '@sentry/browser'
29 | }
30 |
31 | if (SENTRY_DSN && SENTRY_ORG && SENTRY_PROJECT && SENTRY_AUTH_TOKEN && NODE_ENV === 'production') {
32 | config.plugins.push(
33 | new SentryWebpackPlugin({
34 | include: '.next',
35 | ignore: ['node_modules'],
36 | urlPrefix: '~/_next',
37 | release: options.buildId,
38 | })
39 | )
40 | }
41 |
42 | return config
43 | },
44 | })
45 | )
46 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "auralite-web",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "eslint --fix ."
10 | },
11 | "dependencies": {
12 | "@sentry/browser": "5.20.1",
13 | "@sentry/node": "^5.20.1",
14 | "@sentry/webpack-plugin": "^1.12.0",
15 | "@stripe/stripe-js": "^1.8.0",
16 | "@tailwindcss/typography": "^0.2.0",
17 | "@tailwindcss/ui": "^0.5.0",
18 | "@zeit/next-source-maps": "0.0.4-canary.1",
19 | "axios": "^0.19.2",
20 | "chrome-aws-lambda": "^5.2.0",
21 | "date-fns": "^2.15.0",
22 | "fast-glob": "^3.2.4",
23 | "fathom-client": "^3.0.0",
24 | "gray-matter": "^4.0.2",
25 | "js-cookie": "^2.2.1",
26 | "next": "9.5.2",
27 | "next-cookies": "^2.0.3",
28 | "next-mdx-remote": "^0.6.0",
29 | "next-pwa": "^3.1.1",
30 | "nprogress": "^0.2.0",
31 | "pipeline-js": "^1.0.2",
32 | "postcss-100vh-fix": "^0.1.1",
33 | "puppeteer-core": "^5.2.1",
34 | "react": "^16.13.1",
35 | "react-circular-progressbar": "^2.0.3",
36 | "react-dom": "^16.13.1",
37 | "react-easy-swipe": "^0.0.18",
38 | "react-intersection-observer": "^8.26.2",
39 | "react-medium-image-zoom": "^4.3.1",
40 | "react-portal": "^4.2.1",
41 | "react-string-replace": "^0.4.4",
42 | "react-transition-group": "^4.4.1",
43 | "sass": "^1.26.10",
44 | "swr": "^0.3.0",
45 | "tailwindcss": "^1.6.2"
46 | },
47 | "devDependencies": {
48 | "babel-eslint": "^10.1.0",
49 | "eslint": "^7.6.0",
50 | "eslint-config-prettier": "^6.11.0",
51 | "eslint-plugin-jsx-a11y": "^6.3.1",
52 | "eslint-plugin-prettier": "^3.1.4",
53 | "eslint-plugin-react": "^7.20.5",
54 | "file-loader": "^6.0.0",
55 | "postcss-selector-parser": "^6.0.2",
56 | "prettier": "^2.0.5"
57 | },
58 | "resolutions": {
59 | "webpack": "^5.0.0-beta.25"
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: ['tailwindcss', 'autoprefixer', 'postcss-100vh-fix'],
3 | }
4 |
--------------------------------------------------------------------------------
/public/.well-known/assetlinks.json:
--------------------------------------------------------------------------------
1 | [{
2 | "relation": ["delegate_permission/common.handle_all_urls"],
3 | "target": {
4 | "namespace": "android_app",
5 | "package_name": "io.auralite.mobile",
6 | "sha256_cert_fingerprints": [
7 | "50:44:E0:9F:0A:0F:2E:CA:A1:E8:73:72:12:F1:C6:8B:93:E8:DA:46:62:89:00:13:BA:DF:BF:18:51:17:04:C8"
8 | ]
9 | }
10 | }]
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/auralite/web/a51a8f4821458c819ec9df7b2bd34759cc7607b3/public/favicon.ico
--------------------------------------------------------------------------------
/public/fonts/Verveine/Verveine.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/auralite/web/a51a8f4821458c819ec9df7b2bd34759cc7607b3/public/fonts/Verveine/Verveine.eot
--------------------------------------------------------------------------------
/public/fonts/Verveine/Verveine.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/auralite/web/a51a8f4821458c819ec9df7b2bd34759cc7607b3/public/fonts/Verveine/Verveine.ttf
--------------------------------------------------------------------------------
/public/fonts/Verveine/Verveine.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/auralite/web/a51a8f4821458c819ec9df7b2bd34759cc7607b3/public/fonts/Verveine/Verveine.woff
--------------------------------------------------------------------------------
/public/fonts/Verveine/Verveine.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/auralite/web/a51a8f4821458c819ec9df7b2bd34759cc7607b3/public/fonts/Verveine/Verveine.woff2
--------------------------------------------------------------------------------
/public/img/articles/progress-update-1/card.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/auralite/web/a51a8f4821458c819ec9df7b2bd34759cc7607b3/public/img/articles/progress-update-1/card.jpg
--------------------------------------------------------------------------------
/public/img/articles/progress-update-2/card.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/auralite/web/a51a8f4821458c819ec9df7b2bd34759cc7607b3/public/img/articles/progress-update-2/card.jpg
--------------------------------------------------------------------------------
/public/img/avatar.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/auralite/web/a51a8f4821458c819ec9df7b2bd34759cc7607b3/public/img/avatar.jpg
--------------------------------------------------------------------------------
/public/img/bg-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/auralite/web/a51a8f4821458c819ec9df7b2bd34759cc7607b3/public/img/bg-dark.png
--------------------------------------------------------------------------------
/public/img/card.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/auralite/web/a51a8f4821458c819ec9df7b2bd34759cc7607b3/public/img/card.jpg
--------------------------------------------------------------------------------
/public/img/icons/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/auralite/web/a51a8f4821458c819ec9df7b2bd34759cc7607b3/public/img/icons/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/img/icons/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/auralite/web/a51a8f4821458c819ec9df7b2bd34759cc7607b3/public/img/icons/android-chrome-512x512.png
--------------------------------------------------------------------------------
/public/img/icons/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/auralite/web/a51a8f4821458c819ec9df7b2bd34759cc7607b3/public/img/icons/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/img/icons/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | #603cba
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/public/img/icons/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/auralite/web/a51a8f4821458c819ec9df7b2bd34759cc7607b3/public/img/icons/favicon-16x16.png
--------------------------------------------------------------------------------
/public/img/icons/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/auralite/web/a51a8f4821458c819ec9df7b2bd34759cc7607b3/public/img/icons/favicon-32x32.png
--------------------------------------------------------------------------------
/public/img/icons/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/auralite/web/a51a8f4821458c819ec9df7b2bd34759cc7607b3/public/img/icons/mstile-150x150.png
--------------------------------------------------------------------------------
/public/img/icons/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 | Created by potrace 1.11, written by Peter Selinger 2001-2013
--------------------------------------------------------------------------------
/public/img/icons/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Auralite",
3 | "short_name": "Auralite",
4 | "icons": [
5 | {
6 | "src": "/img/icons/android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/img/icons/android-chrome-512x512.png",
12 | "sizes": "512x512",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#6875f5",
17 | "background_color": "#6875f5",
18 | "display": "standalone",
19 | "prefer_related_applications": true,
20 | "related_applications": [
21 | {
22 | "platform": "play",
23 | "id": "io.auralite.mobile"
24 | }
25 | ],
26 | "start_url": "https://auralite.io/home"
27 | }
28 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Disallow:
--------------------------------------------------------------------------------
/src/articles/progress-update-1.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | subheading: "Auralite Bi-Weekly Update #1"
3 | title: Going Open-Source & A Peek of the Onboarding
4 | description: The first Auralite progress update, in which I talk about the onboarding, going open source and more.
5 | date: "2020-06-30T18:05:31Z"
6 | image: /img/articles/progress-update-1/card.jpg
7 | ---
8 |
9 | **👋 Hi! I'm Miguel Piedrafita, the maker behind Auralite.** After [announcing Auralite a few days ago](https://twitter.com/m1guelpf/status/1275815147248979968), I've been hard at work adding new features and fixing bugs, getting the platform ready for the beta. Here's what's new.
10 |
11 | ## A peek at the onboarding
12 |
13 | I'm sure you can't wait to see how Auralite looks, so I decided to record **a quick video showing how creating an account feels like**. The design is still a work in progress, but the structure is there.
14 |
15 |
16 |
17 | Keep in mind the final product will include more documentation regarding why each step is required, and that the ordering isn't final.
18 |
19 | ## Platform updates: Open Source!
20 |
21 | *Openness is a big part of Auralite*, and our Open API is the best example of this value. To illustrate the capabilites of this API and create a reference others can follow when building custom clients or integrations, I've decided to extract the Auralite frontend from the backend and release it as open-source. That's right, **the Auralite frontend will be completely open-source, and make use of the same APIs you have access to**!
22 |
23 | I've been hard at work migrating the web client to use Next.js. You can check out my progress (and contribute) [on GitHub](https://github.com/auralite/web).
24 |
25 | And, while we're in the topic of clients, [Matthew Gleich](https://twitter.com/MattGleich) is working on an **unofficial mobile client** with Flutter! It's still on its early stages, but you can see his code [on GitHub](https://github.com/Matt-Gleich/auralite-mobile/).
26 |
27 | ## Thank You
28 |
29 | I wanted to take a moment to **thank everyone that has decided to give Auralite a chance**. The reception has been amazing, and I'll try to get everyone on the platform as soon as possible!
30 |
31 | The first wave of invites (a really small one, only 10 people to start testing out stuff) is already out, but **a second wave will be going out in a few days** and will include a few more people.
32 |
33 | If you haven't already, make sure to sign up for early-access below so we can send you your Auralite invite when everything's ready.
--------------------------------------------------------------------------------
/src/articles/progress-update-2.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | subheading: "Auralite Bi-Weekly Update #2"
3 | title: A Mobile App, New Features & Future Plans.
4 | description: A recap of my last two weeks of work on Auralite, including migrating to Next.js, getting feature-parity with other social networks, going mobile and adding more features.
5 | date: "2020-07-16T05:00:00Z"
6 | image: /img/articles/progress-update-2/card.jpg
7 | ---
8 |
9 | **👋 Hi! I'm Miguel Piedrafita, the maker behind Auralite.** I've been hard at work on Auralite since [the last update](https://auralite.io/blog/progress-update-1). Here's a recap of everything I got done on these two weeks, and what I have planned for the next two.
10 |
11 | ## 🛠 Migrating to Next.js
12 |
13 | I started building Auralite with the stack I'm most used to, a monolithic PHP app powered by Laravel. A week into building the app, however, I decided to switch to a different model, with an open-source SPA powered by our public API. This not only served our _openness_ goal and set an example for developing custom Auralite clients, but made the experience much smoother.
14 |
15 | Anyway, I had to migrate all my existing, backend-supported code to a Next.js app and figure out how to handle authentication, state, and communication. This turned out to be easier than I expected, and I managed to get most of the migration done in two days.
16 |
17 | ## 📱 A mobile app
18 |
19 | With the Next.js migration out of the way, I started brainstorming ideas for **providing a great mobile experience**. After looking into React Native and Flutter, I decided to roll with a Progressive Web App, since it'd allow me to get something out in the least time. The initial setup was pretty easy, as the frontend was already fully-responsive, and a Webpack plugin took me most of the way to where I wanted.
20 |
21 | Once that was done, I started looking into distribution. iOS seems to be a lost cause regarding PWAs, but they provide decent support and allow installation via Safari, which is something. Android, on the other hand, allows you to package your PWA into a native Android app and release it on the Play Store, [which is just what I did](https://play.google.com/store/apps/details?id=io.auralite.mobile).
22 |
23 | ## 🌐 Network feature parity
24 |
25 | Which is the nerdy way of saying that **Auralite now has enough features to be called a proper social network**. You can post, reply, add images to your posts, edit your profile, view others' profiles, etc. (there's no following right now since there aren't many people, but that'll be added down the line) It also has search, mentions and notifications. This won't seem very exciting, but it was required so I could start working on the cool stuff, *the things that make Auralite different* from every other platform out there.
26 |
27 | Oh, and **profiles are now public**! The whole platform required login before, but you can now view anyone's profile without an Auralite account. [Here's mine](https://auralite.io/miguel).
28 |
29 | ## ✨ New Auralite features
30 |
31 | I had some extra time after all that work and started sketching out some of those Auralite-unique features. Since I was gonna make profiles public, I started by **allowing users to control who can view their posts** on a per-post basis. You can only choose between Everyone and Auralite users right now, but I plan to add more options down the line, as well as a separate toggle for **controlling who can interact with posts**.
32 |
33 | Something from the announcement that resonated with many people was the promise of a platform that embraced open standards, like RSS. With this in mind, I didn't stop at adding **public RSS feeds for each Auralite user** (point your feedreader at [my profile](https://auralite.io/miguel) to try it out), but also added a **private feed with your personal timeline**, so you can read your Auralite feed alongside your favourite blogs.
34 |
35 | Finally, I wanted Auralite profiles to look great when shared, so I made some **dynamic cards that appear when sharing your profile** on sites like Twitter, Telegram, or Slack. [Here's a tweet showing off how it looks](https://twitter.com/m1guelpf/status/1282493331885510656).
36 |
37 | ## 🔮 Plans for the next cycle
38 |
39 | I'm organizing development in **two-week development cycles**. This means I disappear into the coding cave for two weeks, occasionally coming out to share what I'm working on (on [Twitter](https://twitter.com/m1guelpf) and [Auralite](https://auralite.io/miguel)), then write an email to the early-access list and a big recap article like the one you're reading right now. I'm also going to start **dropping a batch of invites at the end of each cycle, starting with this one**.
40 |
41 | The first thing I want to add on the next cycle is **cross-posting to Twitter**. This will be an optional feature and might even be removed in the future, but it's meant to allow everyone to **start using Auralite without losing your existing Twitter audience**. It will also allow me to spend less time on Twiter, which is always a good thing.
42 |
43 | Once that's done, I want to **rework notifications**. They work as we've grown to expect notifications to work (someone mentions, you get a notification), but I want to experiment with ways to *make them less attention-grabbing*, like batching or delaying them.
44 |
45 | Finally, I want to start working on an improved version of the feed. **I want to make it impossible for anyone to get stuck scrolling and scrolling down the feed**, which I constantly find myself doing on Twitter. I have a few ideas on how to improve this, but I'll save those for the next update.
46 |
47 | ## 🙌 Thank You
48 |
49 | Finally, I wanted to take a moment to **thank everyone excited to get their hands into the platform**. Auralite started as my dream for *a better social network*, and it's awesome to see others share this dream.
50 |
51 | If you have any questions, feedback, or ideas, feel free to [email me](mailto:miguel@auralite.io), and I'll be happy to answer you. **Let's make something great, together!**
52 |
53 | *~ Miguel Piedrafita*
--------------------------------------------------------------------------------
/src/components/App/Alert.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import Transition from '../Global/Transition'
3 | import { CheckCircleOutline, CrossSolid } from './Icon'
4 |
5 | const Alert = ({ title, body }) => {
6 | const [visible, setVisible] = useState(true)
7 |
8 | setTimeout(() => setVisible(false), 3000)
9 |
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
{title}
21 |
{body}
22 |
23 |
24 | setVisible(false)} className="inline-flex text-gray-400 dark:text-gray-500 focus:outline-none transition ease-in-out duration-150">
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | )
34 | }
35 |
36 | export default Alert
37 |
--------------------------------------------------------------------------------
/src/components/App/AlertManager.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import AlertContext from '../../context/alerts'
3 | import Alert from './Alert'
4 | import { TransitionGroup } from 'react-transition-group'
5 |
6 | const AlertManager = ({ children }) => {
7 | const [alerts, setAlerts] = useState([])
8 |
9 | return (
10 | <>
11 | {children}
12 | {alerts && (
13 |
14 |
15 | {alerts.map(({ title, body }, i) => (
16 |
17 | ))}
18 |
19 |
20 | )}
21 | >
22 | )
23 | }
24 |
25 | export default AlertManager
26 |
--------------------------------------------------------------------------------
/src/components/App/Avatar.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, memo } from 'react'
2 | import { CircularProgressbar, buildStyles } from 'react-circular-progressbar'
3 | import Client from '../../utils/Client'
4 | import Skeleton from './Skeleton'
5 | import useTailwind from '../../hooks/tailwind'
6 | import { UploadSolid } from './Icon'
7 |
8 | const Avatar = ({ src, className = '', sizeClasses, lazy, children }) => {
9 | const [width, height] = useTailwind(sizeClasses, ['width', 'height'])
10 | const [avatarUrl, setAvatarUrl] = useState(useCDN(src, width, height))
11 |
12 | useEffect(() => {
13 | setAvatarUrl(useCDN(src, width, height))
14 | }, [src])
15 |
16 | return (
17 |
18 | {avatarUrl ?
:
}
19 | {children}
20 |
21 | )
22 | }
23 |
24 | export const UploadableAvatar = ({ shouldAllowUploads = true, onChange, src, ...props }) => {
25 | const [file, setFile] = useState(null)
26 | const [source, setSource] = useState(src)
27 | const [progress, setProgress] = useState(0)
28 |
29 | useEffect(() => {
30 | setSource(src)
31 | }, [src])
32 |
33 | useEffect(() => {
34 | if (!file) return
35 |
36 | Client.uploadFile({ file, progress: (progress) => setProgress(Math.round(progress * 100)) })
37 | .catch(() => alert('Something went wrong when uploading your profile pic'))
38 | .then((response) => onChange(`${response.key}.${response.extension}`))
39 | }, [file])
40 |
41 | useEffect(() => {
42 | if (progress !== 100) return
43 |
44 | setTimeout(() => {
45 | setSource(URL.createObjectURL(file))
46 |
47 | setProgress(0)
48 | }, 1000)
49 | }, [progress])
50 |
51 | return (
52 |
53 | {shouldAllowUploads && (
54 | <>
55 |
56 |
57 |
58 |
59 |
60 | setFile(event.target.files[0])} accept="image/jpeg,image/png" />
61 |
62 | >
63 | )}
64 |
65 | )
66 | }
67 |
68 | const useCDN = (src, width, height) => {
69 | if (!src?.startsWith('https://auralite.s3.eu-west-2.amazonaws.com/')) return src
70 |
71 | return `https://ik.imagekit.io/auralite/tr:w-${parseFloat(width.split('rem')[0]) * 24},h-${parseFloat(height.split('rem')[0]) * 24}/${src.split('https://auralite.s3.eu-west-2.amazonaws.com/', 2)[1]}`
72 | }
73 |
74 | export default memo(Avatar)
75 |
--------------------------------------------------------------------------------
/src/components/App/ClientOnly.js:
--------------------------------------------------------------------------------
1 | const { useState, useEffect } = require('react')
2 |
3 | const ClientOnly = ({ children }) => {
4 | if (isSSR()) return null
5 |
6 | return children
7 | }
8 |
9 | export const isSSR = () => {
10 | const [hasMounted, setHasMounted] = useState(false)
11 |
12 | useEffect(() => {
13 | setHasMounted(true)
14 | }, [])
15 |
16 | return !hasMounted
17 | }
18 |
19 | export default ClientOnly
20 |
--------------------------------------------------------------------------------
/src/components/App/Compose.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, forwardRef, memo } from 'react'
2 | import Client from '../../utils/Client'
3 | import LoadingButton from './LoadingButton'
4 | import Avatar from './Avatar'
5 | import ImageGrid, { useImageGrid } from './ImageGrid'
6 | import useUser from '@/hooks/user'
7 | import { ImageOutline, GlobeOutline, UserCircleOutline } from './Icon'
8 |
9 | const Compose = forwardRef(({ replyTo, onPost = () => {} }, ref) => {
10 | const { user } = useUser()
11 |
12 | const [loading, setLoading] = useState(false)
13 | const [error, setError] = useState(null)
14 |
15 | const [post, setPost] = useState('')
16 | const remainingChars = 300 - post.length
17 |
18 | const { uploaderSettings, gridSettings, showGrid, hasPendingImages, images, cleanupImages } = useImageGrid()
19 | const [privacy, setPrivacy] = useState('public')
20 |
21 | const updatePost = (content) => {
22 | setPost(content.replace(/\n{3,}/m, '\n\n'))
23 |
24 | setError(null)
25 | }
26 |
27 | useEffect(() => {
28 | setPrivacy(replyTo?.privacy ?? 'public')
29 | }, [replyTo?.privacy])
30 |
31 | const submitForm = (event) => {
32 | event.preventDefault()
33 |
34 | setLoading(true)
35 |
36 | Client.createPost({ post: post.trim(), privacy, reply_to: replyTo?.id, images })
37 | .then((post) => {
38 | onPost(post)
39 | setLoading(false)
40 | setError(null)
41 | setPost('')
42 | cleanupImages()
43 | })
44 | .catch((error) => {
45 | if (!error.response?.data?.errors) return alert('Something went wrong when creating your post.')
46 |
47 | setError(error.response.data.errors.content[0])
48 | setLoading(false)
49 | })
50 | }
51 |
52 | return (
53 |
100 | )
101 | })
102 |
103 | Compose.displayName = 'Compose'
104 |
105 | export default memo(Compose)
106 |
--------------------------------------------------------------------------------
/src/components/App/ImageGrid.js:
--------------------------------------------------------------------------------
1 | import { Controlled as Zoom } from 'react-medium-image-zoom'
2 | import { useState, useMemo, useEffect, memo } from 'react'
3 | import Client from '../../utils/Client'
4 | import { CrossSolid } from './Icon'
5 | import useTheme from '@/hooks/theme'
6 |
7 | const ImageGrid = ({ imageRows, onImageRemove, onUpload, imageCount, isUpload }) => (
8 |
9 |
10 |
11 |
12 |
13 | {imageRows.map((images, key) => (
14 |
15 | {images.map((image, i) => (isUpload ? onUpload(image.id, key)} /> : ))}
16 |
17 | ))}
18 |
19 |
20 |
21 |
22 |
23 | )
24 |
25 | const ImageUpload = ({ image, imageCount, onRemove, onKey }) => {
26 | const [isZoomed, setIsZoomed] = useState(false)
27 | const [progress, setProgress] = useState(0)
28 |
29 | useEffect(() => {
30 | if (image.key) return
31 |
32 | Client.uploadFile({
33 | file: image.file,
34 | progress: (progress) => setProgress(Math.round(progress * 100)),
35 | })
36 | .catch(() => alert('Something went wrong when uploading your image'))
37 | .then((response) => {
38 | setTimeout(() => setProgress(0), 1000)
39 |
40 | onKey(`${response.key}.${response.extension}`)
41 | })
42 | }, [image])
43 |
44 | return (
45 | 1 ? 'h-1/2' : 'h-full'}`}>
46 |
47 |
setIsZoomed(zoomState)}>
48 |
49 |
50 | {onRemove && (
51 |
onRemove(image.id)} className="shadow cursor-pointer absolute top-0 right-0 p-1 sm:p-2 mr-1 sm:mr-2 mt-1 sm:mt-2 rounded-full bg-gray-600">
52 |
53 |
54 | )}
55 |
56 | )
57 | }
58 |
59 | const Image = ({ image, imageCount }) => {
60 | const [isZoomed, setIsZoomed] = useState(false)
61 | const { isDark } = useTheme()
62 | const src = useMemo(() => {
63 | return image?.startsWith('https://auralite.s3.eu-west-2.amazonaws.com/') ? `https://ik.imagekit.io/auralite/${image.split('https://auralite.s3.eu-west-2.amazonaws.com/', 2)[1]}` : image
64 | }, [image])
65 |
66 | return (
67 | 1 ? 'h-1/2' : 'h-full'}`}>
68 |
setIsZoomed(zoomState)} overlayBgColorEnd={isDark ? 'rgba(0, 0, 0, 0.75)' : 'rgba(255, 255, 255, 0.95)'}>
69 |
70 |
71 |
72 |
73 |
74 | )
75 | }
76 |
77 | const chunkImages = (images) =>
78 | useMemo(() => {
79 | if (!images) return []
80 |
81 | switch (images.length) {
82 | case 1:
83 | case 2:
84 | return images.map((image) => [image])
85 |
86 | case 3:
87 | return [[images[0]], [images[1], images[2]]]
88 |
89 | case 4:
90 | return [
91 | [images[0], images[1]],
92 | [images[2], images[3]],
93 | ]
94 | }
95 | }, [images])
96 |
97 | export const useImageGrid = (images = null, forceImages = false) => {
98 | if (!images && !forceImages) return useWithUploads()
99 |
100 | return { imageRows: chunkImages(images), imageCount: images?.length ?? 0, isUpload: false }
101 | }
102 |
103 | const useWithUploads = () => {
104 | const [images, setImages] = useState([])
105 |
106 | const [uploadImages, setUploadImages] = useState({})
107 |
108 | const onImageAdd = (files) => {
109 | if (images.length >= 4) return
110 |
111 | const id = Math.random().toString(36).substring(7)
112 |
113 | setImages((images) => images.concat([...files].map((file) => ({ id, localUrl: URL.createObjectURL(file), file }))))
114 | }
115 |
116 | const onImageRemove = (id) => {
117 | setImages((images) => images.filter((image) => image.id !== id))
118 |
119 | setUploadImages((state) => {
120 | delete state[id]
121 |
122 | return state
123 | })
124 | }
125 |
126 | const onUpload = (id, key) => {
127 | setUploadImages((state) => {
128 | state[id] = key
129 |
130 | return state
131 | })
132 | }
133 |
134 | const cleanupImages = () => {
135 | setUploadImages({})
136 | setImages([])
137 | }
138 |
139 | return { uploaderSettings: { onChange: (event) => onImageAdd(event.target.files) }, gridSettings: { imageRows: chunkImages(images), onImageRemove, onUpload, imageCount: images.length, isUpload: true }, showGrid: images.length > 0, hasPendingImages: images.length != Object.keys(uploadImages).length, images: Object.values(uploadImages), cleanupImages }
140 | }
141 |
142 | export default memo(ImageGrid)
143 |
--------------------------------------------------------------------------------
/src/components/App/LoadLink.js:
--------------------------------------------------------------------------------
1 | import Link from 'next/link'
2 |
3 | const LoadLink = ({ deps, children, ...props }) => <>{deps ? {children} : children}>
4 |
5 | export default LoadLink
6 |
--------------------------------------------------------------------------------
/src/components/App/LoadingButton.js:
--------------------------------------------------------------------------------
1 | const { AnimatedLoading } = require('./Icon')
2 |
3 | const LoadingButton = ({ loading, disabled, loadingClasses, disabledClasses, activeClasses, wrapperClasses, children, className, onClick }, props) => (
4 | e.preventDefault() : onClick} className={`relative ${className} ${loading ? loadingClasses : ''} ${disabled ? disabledClasses : ''} ${!loading && !disabled ? activeClasses : ''}`} {...props}>
5 | {loading && (
6 |
7 |
8 |
9 | )}
10 | {children}
11 |
12 | )
13 |
14 | export default LoadingButton
15 |
--------------------------------------------------------------------------------
/src/components/App/Modal.js:
--------------------------------------------------------------------------------
1 | import Transition from '../Global/Transition'
2 |
3 | const Modal = ({ isVisible, onClose = () => {}, children }) => {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | {children}
15 |
16 |
17 |
18 | )
19 | }
20 |
21 | export default Modal
22 |
--------------------------------------------------------------------------------
/src/components/App/Navigation/BottomNav.js:
--------------------------------------------------------------------------------
1 | import Link from 'next/link'
2 | import { useRouter } from 'next/router'
3 | import Avatar from '../Avatar'
4 | import { HomeOutline, SearchOutline, BellOutline, HomeSolid, SearchSolid, BellSolid } from '../Icon'
5 | import useUser from '@/hooks/user'
6 | import useNotifications from '@/hooks/notifications'
7 | import { memo } from 'react'
8 |
9 | const BottomNav = () => {
10 | const { user } = useUser()
11 | const { notifications } = useNotifications()
12 |
13 | return (
14 |
33 | )
34 | }
35 |
36 | const NavLink = ({ children, className = '', ...props }) => {
37 | const router = useRouter()
38 |
39 | const isActive = props.as ? router.asPath === props.as : router.pathname === props.href
40 |
41 | return (
42 |
43 | {children(isActive)}
44 |
45 | )
46 | }
47 |
48 | export default memo(BottomNav)
49 |
--------------------------------------------------------------------------------
/src/components/App/Navigation/SideNav.js:
--------------------------------------------------------------------------------
1 | import Transition from '../../Global/Transition'
2 | import Logo from '../../Global/Logo'
3 | import { useRouter } from 'next/router'
4 | import Link from 'next/link'
5 | import Avatar from '../Avatar'
6 | import Skeleton from '../Skeleton'
7 | import { logout } from '@/utils/auth'
8 | import useUser from '@/hooks/user'
9 | import { memo } from 'react'
10 | import { HomeOutline, SearchOutline, BellOutline, UserCircleOutline, CogOutline, HomeSolid, SearchSolid, BellSolid, UserCircleSolid, CogSolid } from '../Icon'
11 | import ClientOnly from '../ClientOnly'
12 | import { ThemeToggle } from '@/components/Global/ThemeManager'
13 |
14 | const SideNav = ({ isOpen, onClose }) => {
15 | const { user } = useUser()
16 |
17 | return (
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
{user?.profile?.name ?? }
30 |
31 |
32 |
33 | {(active) => (
34 | <>
35 | {active ? : }
36 | Home
37 | >
38 | )}
39 |
40 |
41 | {(active) => (
42 | <>
43 | {active ? : }
44 | Search
45 | >
46 | )}
47 |
48 |
49 | {(active) => (
50 | <>
51 | {active ? : }
52 | Notifications
53 | >
54 | )}
55 |
56 |
57 | {(active) => (
58 | <>
59 | {active ? : }
60 | Profile
61 | >
62 | )}
63 |
64 |
65 | {(active) => (
66 | <>
67 | {active ? : }
68 | Settings
69 | >
70 | )}
71 |
72 |
73 |
74 |
75 |
76 | Theme
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 | Auralite
92 | {' '}
93 |
94 | beta ({process.env.commitHash.substring(0, 6)})
95 |
96 |
97 |
98 | Log Out
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 | )
109 | }
110 |
111 | const NavLink = ({ children, href, as, onClick, ...props }) => {
112 | const router = useRouter()
113 |
114 | return (
115 |
116 | {/* eslint-disable-next-line */}
117 |
118 | {children(router.asPath === (as ?? href))}
119 |
120 |
121 | )
122 | }
123 | export default memo(SideNav)
124 |
--------------------------------------------------------------------------------
/src/components/App/Navigation/TopNav.js:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router'
2 | import Link from 'next/link'
3 | import { authCheck } from '@/middleware/auth'
4 | import Logo from '@/components/Global/Logo'
5 | import Avatar from '../Avatar'
6 | import { useState, memo } from 'react'
7 | import { logout } from '@/utils/auth'
8 | import useClickOutside from '@/hooks/click-outside'
9 | import Transition from '@/components/Global/Transition'
10 | import useUser from '@/hooks/user'
11 | import useTheme from '@/hooks/theme'
12 |
13 | const TopNav = ({ title, openSideNav }) => {
14 | const router = useRouter()
15 | const { user } = useUser()
16 | const { toggleTheme } = useTheme()
17 | const [isOpen, setOpen] = useState(false)
18 |
19 | const { ref: profileRef } = useClickOutside(() => {
20 | if (!isOpen) return
21 |
22 | setOpen(false)
23 | })
24 |
25 | return (
26 |
27 |
28 |
29 |
30 |
31 |
(authCheck ? openSideNav() : router.push('/'))} className={`sm:hidden flex-shrink-0 ${authCheck ? 'focus:outline-none focus:shadow-outline rounded-full' : ''}`} aria-label="Menu">
32 | {authCheck ? : }
33 |
34 |
35 |
36 |
37 |
38 |
39 | {title &&
{title}
}
40 |
41 | {authCheck && (
42 |
43 |
44 |
45 |
46 |
47 |
48 | )}
49 |
50 | {authCheck ? (
51 |
52 |
53 |
54 |
55 |
setOpen((state) => !state)} className="hidden max-w-xs md:flex items-center text-sm rounded-full text-white dark:text-gray-300 focus:outline-none focus:shadow-solid" id="user-menu" aria-label="User menu" aria-haspopup="true">
56 |
57 |
58 |
61 |
62 |
63 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 | ) : (
91 |
92 |
Log In
93 |
94 | )}
95 |
96 |
97 |
98 | )
99 | }
100 |
101 | const NavItem = ({ href, label }) => {
102 | const router = useRouter()
103 |
104 | return (
105 |
106 | {label}
107 |
108 | )
109 | }
110 |
111 | export default memo(TopNav)
112 |
--------------------------------------------------------------------------------
/src/components/App/Notification.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react'
2 | import Client from '../../utils/Client'
3 | import Post from './Post'
4 | import { useInView } from 'react-intersection-observer'
5 | import { ReplySolid, AtSolid } from './Icon'
6 | import Skeleton from './Skeleton'
7 |
8 | const Notification = ({ type, ...notification }) => {
9 | switch (type) {
10 | case 'mention':
11 | return
12 |
13 | case 'reply':
14 | return
15 |
16 | case undefined:
17 | return
18 |
19 | default:
20 | throw 'Unknown notification type'
21 | }
22 | }
23 |
24 | const LoadingNotification = () => (
25 |
26 |
30 |
31 |
32 | }
33 | />
34 |
35 | )
36 |
37 | const NotificationSkeleton = ({ post, read, children, id }) => {
38 | const [ref, inView] = useInView({ threshold: 1, triggerOnce: true })
39 |
40 | useEffect(() => {
41 | if (!inView || read) return
42 |
43 | Client.markNotificationRead({ id })
44 | }, [inView])
45 |
46 | const meta = {children}
47 |
48 | return
49 | }
50 |
51 | const MentionNotification = ({ post, author, unread, id }) => (
52 |
53 |
54 | {author.name} mentioned you
55 |
56 | )
57 |
58 | const ReplyNotification = ({ post, author, unread, id }) => (
59 |
60 |
61 | {author.name} replied to you
62 |
63 | )
64 |
65 | export default Notification
66 |
--------------------------------------------------------------------------------
/src/components/App/PageLayout.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import Head from '../Global/Head'
3 | import BaseLayout from '../Global/BaseLayout'
4 | import SideNav from './Navigation/SideNav'
5 | import BottomNav from './Navigation/BottomNav'
6 | import TopNav from './Navigation/TopNav'
7 | import { authCheck } from '@/middleware/auth'
8 |
9 | const PageLayout = ({ children, title, middleware }) => {
10 | const [mobileNavigationOpen, setMobileNavigationOpen] = useState(false)
11 |
12 | return (
13 |
14 |
15 | setMobileNavigationOpen(false)} />
16 |
17 |
setMobileNavigationOpen(true)} />
18 |
19 |
20 |
21 | {children}
22 |
23 |
24 | {authCheck && }
25 |
26 |
27 | )
28 | }
29 |
30 | export const usePageLayout = (title) => (page, props = {}) => (
31 |
32 | {page}
33 |
34 | )
35 |
36 | export default PageLayout
37 |
--------------------------------------------------------------------------------
/src/components/App/Post.js:
--------------------------------------------------------------------------------
1 | import { fromUnixTime, format } from 'date-fns'
2 | import Skeleton from '@/components/App/Skeleton'
3 | import Link from 'next/link'
4 | import Avatar from './Avatar'
5 | import useFormat from '@/hooks/format'
6 | import Client from '@/utils/Client'
7 | import { useState, Fragment, forwardRef, memo, useEffect } from 'react'
8 | import useClickOutside from '@/hooks/click-outside'
9 | import Transition from '../Global/Transition'
10 | import ImageGrid, { useImageGrid } from './ImageGrid'
11 | import useUser from '@/hooks/user'
12 | import { ChevronDownOutline, TrashOutline } from './Icon'
13 | import { useInView } from 'react-intersection-observer'
14 |
15 | const Post = forwardRef(({ post, shouldLink = true, isParent = false, showParent = true, meta, featured = false, showOptions = true, onDelete = () => {}, withBorder = true, isSkeleton = false, shouldTrack = false }, ref) => {
16 | const postContent = useFormat(post?.content)
17 | const [optionsOpen, setOptionsOpen] = useState(false)
18 | const { ref: optionsRef, excludeRef } = useClickOutside(() => {
19 | if (!optionsOpen) return
20 |
21 | setOptionsOpen(false)
22 | })
23 |
24 | const { user } = useUser(post && showOptions)
25 |
26 | var inView = [null]
27 |
28 | if (shouldTrack && !isParent) {
29 | // I'd love to destructure this, but I need it to be a single variable so that the scope is kept outside the conditional. Better code is welcome.
30 | inView = useInView({ threshold: 1, triggerOnce: true, rootMargin: '-50px 0px' })
31 |
32 | useEffect(() => {
33 | if (!shouldTrack || !inView[1] || !post) return
34 |
35 | Client.markPostRead({ postId: post.id })
36 | }, [inView[1]])
37 |
38 | console.log({ inView: inView[1], ref: inView[0].current, post: post?.content })
39 | }
40 |
41 | const Wrapper = shouldLink ? Link : 'div'
42 | const ChildWrapper = shouldLink ? 'div' : Fragment
43 |
44 | const openDropdown = (event) => {
45 | event.stopPropagation()
46 |
47 | setOptionsOpen((state) => !state)
48 | }
49 |
50 | const deletePost = () => {
51 | Client.deletePost({ postId: post.id })
52 | .catch(() => alert('Something went wrong when deleting your post.'))
53 | .then(() => onDelete(post))
54 | }
55 |
56 | const gridSettings = useImageGrid(post?.media, true)
57 |
58 | const parentClasses = `px-4 ${isParent ? '' : `border-b border-gray-200 dark:border-gray-800 ${withBorder ? '' : 'sm:border-b-0'}`} ${post?.parent ? 'pt-1' : 'pt-5'} pb-5 w-full group`
59 |
60 | return (
61 |
62 | {!isSkeleton && post?.parent && showParent &&
}
63 |
{
69 | if (ref) ref.current = element
70 | if (shouldTrack && !isParent) inView[0].current = element
71 | },
72 | })}
73 | >
74 | {
79 | if (ref) ref.current = element
80 | if (shouldTrack && !isParent) inView[0].current = element
81 | },
82 | }
83 | : {})}
84 | >
85 | <>
86 | {meta && {meta}
}
87 |
88 |
89 |
90 | {isParent &&
}
91 |
92 |
93 |
94 |
95 |
96 |
97 | {post?.author?.name ?? }
98 | {post?.author_handle ? `@${post.author_handle}` : }
99 |
100 |
101 |
102 | ·
103 | {post?.created_at ? format(fromUnixTime(post.created_at), 'MMM dd') : }
104 |
105 |
106 | {showOptions && post?.author_handle && user?.profile?.handle === post?.author_handle && (
107 |
108 |
111 |
112 | {/* eslint-disable-next-line */}
113 | event.stopPropagation()} className="origin-top-right absolute right-0 mt-2 w-56 z-20 rounded-md shadow-lg">
114 |
115 |
116 |
117 |
118 | Delete Post
119 |
120 |
121 |
122 |
123 |
124 |
125 | )}
126 |
127 |
128 |
{postContent[0] !== undefined ? postContent : }
129 | {post?.media?.length > 0 && (
130 |
131 |
132 |
133 | )}
134 |
135 |
136 |
137 | >
138 |
139 |
140 |
141 | )
142 | })
143 |
144 | Post.displayName = 'Post'
145 |
146 | export default memo(Post)
147 |
--------------------------------------------------------------------------------
/src/components/App/Skeleton.js:
--------------------------------------------------------------------------------
1 | const Skeleton = ({ count = 1, width, wrapper: Wrapper, height, circle = false, style: customStyle = {}, className = '' }) => {
2 | const elements = []
3 |
4 | for (let i = 0; i < count; i++) {
5 | let style = {}
6 |
7 | if (width !== null) {
8 | style.width = width
9 | }
10 |
11 | if (height !== null) {
12 | style.height = height
13 | }
14 |
15 | elements.push(
16 |
17 |
18 |
19 | )
20 | }
21 |
22 | return (
23 |
24 | {Wrapper
25 | ? elements.map((element, i) => (
26 |
27 | {element}
28 |
29 |
30 | ))
31 | : elements}
32 |
33 | )
34 | }
35 |
36 | export default Skeleton
37 |
--------------------------------------------------------------------------------
/src/components/App/Tabs.js:
--------------------------------------------------------------------------------
1 | import { Fragment } from 'react'
2 | import Link from 'next/link'
3 |
4 | const Tabs = ({ tabs }) => (
5 | <>
6 | {tabs.map(({ active, tag: Tag = 'a', className = '', content, isLink = false, ...props }, i) => {
7 | const Wrapper = isLink ? Link : Fragment
8 |
9 | return (
10 |
11 |
12 | {content}
13 |
14 |
15 | )
16 | })}
17 | >
18 | )
19 |
20 | export default Tabs
21 |
--------------------------------------------------------------------------------
/src/components/Global/BaseLayout.js:
--------------------------------------------------------------------------------
1 | import AlertManager from '../App/AlertManager'
2 | import { useEffect } from 'react'
3 | import Pipeline from 'pipeline-js'
4 | import ThemeManager from './ThemeManager'
5 | import { useRouter } from 'next/router'
6 |
7 | const BaseLayout = ({ children, middleware }) => {
8 | const router = useRouter()
9 |
10 | useEffect(() => {
11 | new Pipeline(middleware).process()
12 | }, [router.pathname])
13 |
14 | return (
15 |
16 | {children}
17 |
18 | )
19 | }
20 |
21 | export const useBaseLayout = () => (page, props = {}) => {page}
22 |
23 | export default BaseLayout
24 |
--------------------------------------------------------------------------------
/src/components/Global/Head.js:
--------------------------------------------------------------------------------
1 | import { default as NextHead } from 'next/head'
2 | import { useRouter } from 'next/router'
3 |
4 | const Head = ({ children }) => {
5 | const router = useRouter()
6 | return (
7 |
8 | Auralite
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | {children}
37 |
38 | )
39 | }
40 |
41 | export default Head
42 |
--------------------------------------------------------------------------------
/src/components/Global/Logo.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | const Logo = (props) => (
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | )
65 |
66 | export default Logo
67 |
--------------------------------------------------------------------------------
/src/components/Global/ThemeManager.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react'
2 | import ThemeContext from '@/context/theme'
3 | import useStickyState from '@/hooks/sticky'
4 | import useTheme from '@/hooks/theme'
5 | import Swipe from 'react-easy-swipe'
6 | import { SunSolid, MoonSolid } from '../App/Icon'
7 |
8 | const ThemeManager = ({ children }) => {
9 | const [theme, setTheme] = useStickyState(
10 | () => {
11 | return typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)')?.matches ? 'dark' : 'light'
12 | },
13 | 'theme',
14 | false
15 | )
16 |
17 | useEffect(() => {
18 | document.body.classList.add('dark:bg-gray-900', 'sm:dark:bg-gray-700')
19 |
20 | if (theme === 'dark') {
21 | document.documentElement.classList.add('scheme-dark')
22 | } else {
23 | document.documentElement.classList.remove('scheme-dark')
24 | }
25 | }, [theme])
26 |
27 | return {children}
28 | }
29 |
30 | export const ThemeToggle = () => {
31 | const { isDark, toggleTheme } = useTheme()
32 | const [swipeDirection, setSwipeDirection] = useState(null)
33 |
34 | function handleKeyDown(e) {
35 | if ([' ', 'Enter'].includes(e.key)) {
36 | e.preventDefault()
37 | toggleTheme()
38 | }
39 | }
40 |
41 | const registerDirection = (position) => {
42 | if (Math.abs(position.x) < 20) return
43 |
44 | setSwipeDirection(position.x > 0 ? 'right' : 'left')
45 | }
46 |
47 | const performSwipe = () => {
48 | if (swipeDirection === 'right' && !isDark) toggleTheme()
49 | if (swipeDirection === 'left' && isDark) toggleTheme()
50 |
51 | setSwipeDirection(null)
52 | }
53 |
54 | return (
55 |
56 | toggleTheme()} onKeyDown={(e) => handleKeyDown(e)} className={`${isDark ? 'bg-indigo-600' : 'bg-gray-200'} select-none relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:shadow-outline`}>
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 | )
68 | }
69 |
70 | export const ThemeBubble = () => {
71 | const { isDark, toggleTheme } = useTheme()
72 |
73 | return (
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 | )
83 | }
84 |
85 | export default ThemeManager
86 |
--------------------------------------------------------------------------------
/src/components/Global/Transition.js:
--------------------------------------------------------------------------------
1 | import { CSSTransition as ReactCSSTransition } from 'react-transition-group'
2 | import { useRef, useEffect, useContext, createContext } from 'react'
3 |
4 | const TransitionContext = createContext({ parent: {} })
5 |
6 | function useIsInitialRender() {
7 | const isInitialRender = useRef(true)
8 |
9 | useEffect(() => {
10 | isInitialRender.current = false
11 | }, [])
12 |
13 | return isInitialRender.current
14 | }
15 |
16 | function CSSTransition({ show, enter = '', enterFrom = '', enterTo = '', leave = '', leaveFrom = '', leaveTo = '', appear, children }) {
17 | const enterClasses = enter.split(' ').filter((s) => s.length)
18 | const enterFromClasses = enterFrom.split(' ').filter((s) => s.length)
19 | const enterToClasses = enterTo.split(' ').filter((s) => s.length)
20 | const leaveClasses = leave.split(' ').filter((s) => s.length)
21 | const leaveFromClasses = leaveFrom.split(' ').filter((s) => s.length)
22 | const leaveToClasses = leaveTo.split(' ').filter((s) => s.length)
23 |
24 | const addClasses = (node, classes) => classes.length && node.classList.add(...classes)
25 |
26 | const removeClasses = (node, classes) => classes.length && node.classList.remove(...classes)
27 |
28 | return (
29 | {
34 | node.addEventListener('transitionend', done, false)
35 | }}
36 | onEnter={(node) => {
37 | addClasses(node, [...enterClasses, ...enterFromClasses])
38 | }}
39 | onEntering={(node) => {
40 | removeClasses(node, enterFromClasses)
41 | addClasses(node, enterToClasses)
42 | }}
43 | onEntered={(node) => {
44 | removeClasses(node, [...enterToClasses, ...enterClasses])
45 | }}
46 | onExit={(node) => {
47 | addClasses(node, [...leaveClasses, ...leaveFromClasses])
48 | }}
49 | onExiting={(node) => {
50 | removeClasses(node, leaveFromClasses)
51 | addClasses(node, leaveToClasses)
52 | }}
53 | onExited={(node) => {
54 | removeClasses(node, [...leaveToClasses, ...leaveClasses])
55 | }}
56 | >
57 | {children}
58 |
59 | )
60 | }
61 |
62 | function Transition({ show, appear, ...rest }) {
63 | const { parent } = useContext(TransitionContext)
64 | const isInitialRender = useIsInitialRender()
65 | const isChild = show === undefined
66 |
67 | if (isChild) {
68 | return
69 | }
70 |
71 | return (
72 |
81 |
82 |
83 | )
84 | }
85 |
86 | export default Transition
87 |
--------------------------------------------------------------------------------
/src/components/Marketing/ConfirmationModal.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react'
2 | import Transition from '../Global/Transition'
3 | import useClickOutside from '../../hooks/click-outside'
4 |
5 | const ConfirmationModal = () => {
6 | const [visible, setVisible] = useState(false)
7 |
8 | const { ref: hideOnClickOutside } = useClickOutside(() => setVisible(false))
9 |
10 | useEffect(() => {
11 | setVisible(!!window.location.search.match(/confirmed/))
12 | }, [])
13 |
14 | return (
15 |
16 |
17 |
18 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
Social networks are better with friends
44 |
45 |
The more users who join Auralite, the better it'll be. We've even written something for you if you're in a rush. Thank you for helping us get the word out!
46 |
47 |
48 |
49 |
59 |
60 |
61 |
62 |
63 | )
64 | }
65 |
66 | export default ConfirmationModal
67 |
--------------------------------------------------------------------------------
/src/components/Marketing/EarlyAccessForm.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 | import { useState, useEffect } from 'react'
3 |
4 | const EarlyAccessForm = () => {
5 | const [subscribed, setSubscribed] = useState(false)
6 | const [email, setEmail] = useState('')
7 |
8 | useEffect(() => {
9 | setSubscribed(localStorage.getItem('subscribed') == 'true')
10 | }, [])
11 |
12 | const handleForm = async (event) => {
13 | event.preventDefault()
14 |
15 | try {
16 | await axios.post('https://newsletter.m1guelpf.me/api/subscribe/6fd363df-ec34-4a07-ae2d-8e38e5d1a6fd', { email })
17 | } catch (error) {
18 | if (!error.response || error.response.data.code !== 'already_subscribed') return alert(error.response.data.message)
19 | }
20 |
21 | setSubscribed(true)
22 |
23 | if (window.fathom) window.fathom.trackGoal('LQLUQ7ZW', 0)
24 |
25 | localStorage.setItem('subscribed', true)
26 | }
27 |
28 | return (
29 |
30 |
31 | {subscribed ? (
32 |
33 |
You've requested early-access
34 |
If you haven't already, make sure to confirm your subscription by clicking on the link we've sent you.
35 |
36 | ) : (
37 | <>
38 |
39 |
Get early-access
40 |
Learn more about Auralite and be the first one to get access.
41 |
42 |
43 |
44 | setEmail(event.target.value)} />
45 |
46 | Sign me up
47 |
48 |
49 |
Your email will only be used to let you know about Auralite updates and send you your early-access invite. You can unsubscribe at any time.
50 |
51 | >
52 | )}
53 |
54 |
55 | )
56 | }
57 |
58 | export default EarlyAccessForm
59 |
--------------------------------------------------------------------------------
/src/components/Marketing/Video.js:
--------------------------------------------------------------------------------
1 | const Video = ({ url }) => (
2 |
7 | )
8 |
9 | export const YouTube = ({ id }) =>
10 |
11 | export default Video
12 |
--------------------------------------------------------------------------------
/src/context/alerts.js:
--------------------------------------------------------------------------------
1 | import { createContext } from 'react'
2 |
3 | const AlertContext = createContext([])
4 |
5 | AlertContext.displayName = 'AlertContext'
6 |
7 | export default AlertContext
8 |
--------------------------------------------------------------------------------
/src/context/theme.js:
--------------------------------------------------------------------------------
1 | import { createContext } from 'react'
2 |
3 | const ThemeContext = createContext([])
4 |
5 | ThemeContext.displayName = 'ThemeContext'
6 |
7 | export default ThemeContext
8 |
--------------------------------------------------------------------------------
/src/hooks/alert.js:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react'
2 | import AlertContext from '../context/alerts'
3 |
4 | const useAlert = () => {
5 | const { alerts, setAlerts } = useContext(AlertContext)
6 |
7 | const createAlert = ({ title, body }) => setAlerts((alerts) => [...alerts, { title, body }])
8 |
9 | return { alerts, createAlert }
10 | }
11 |
12 | export default useAlert
13 |
--------------------------------------------------------------------------------
/src/hooks/click-outside.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react'
2 |
3 | const useClickOutside = (handleClick) => {
4 | const ref = useRef(null)
5 | const excludeRef = useRef(null)
6 |
7 | useEffect(() => {
8 | const handleClickOutside = (event) => {
9 | if (!ref.current || ref.current.contains(event.target) || (excludeRef.current && excludeRef.current.contains(event.target))) return
10 |
11 | handleClick()
12 | }
13 |
14 | document.addEventListener('mousedown', handleClickOutside)
15 |
16 | return () => {
17 | document.removeEventListener('mousedown', handleClickOutside)
18 | }
19 | }, [ref, excludeRef, handleClick])
20 |
21 | return { ref, excludeRef }
22 | }
23 |
24 | export default useClickOutside
25 |
--------------------------------------------------------------------------------
/src/hooks/format.js:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react'
2 | import replace from 'react-string-replace'
3 | import Link from 'next/link'
4 |
5 | const useFormat = (content, { underlineLinks = false } = {}) => {
6 | return useMemo(() => {
7 | let formatted = replace(content, /([A-Za-z]+:\/\/[A-Za-z0-9-_]+\.[A-Za-z0-9-_:%&~?#/.=]+)/g, (url, i) => {
8 | try {
9 | return (
10 |
11 | {normalizeUrl(url)}
12 |
13 | )
14 | } catch {
15 | return {url}
16 | }
17 | })
18 | formatted = replace(formatted, /([@]+[A-Za-z0-9-_]+)/g, (username, i) => (
19 |
20 | {username}
21 |
22 | ))
23 | return replace(formatted, '\n', (_, key) => )
24 | }, [content])
25 | }
26 |
27 | const normalizeUrl = (urlString) => {
28 | urlString = urlString.trim()
29 |
30 | const hasRelativeProtocol = urlString.startsWith('//')
31 | const isRelativeUrl = !hasRelativeProtocol && /^\.*\//.test(urlString)
32 |
33 | // Prepend protocol
34 | if (!isRelativeUrl) urlString = urlString.replace(/^(?!(?:\w+:)?\/\/)|^\/\//, 'https:')
35 |
36 | let urlObj
37 | try {
38 | urlObj = new URL(urlString)
39 | } catch {
40 | return urlString
41 | }
42 |
43 | // Remove auth
44 | urlObj.username = ''
45 | urlObj.password = ''
46 | urlObj.hash = ''
47 | urlObj.search = ''
48 |
49 | // Decode URI octets
50 | if (urlObj.pathname) {
51 | try {
52 | urlObj.pathname = decodeURI(urlObj.pathname)
53 | // eslint-disable-next-line no-empty
54 | } catch (_) {}
55 | }
56 |
57 | if (urlObj.hostname) {
58 | // Remove trailing dot
59 | urlObj.hostname = urlObj.hostname.replace(/\.$/, '')
60 |
61 | // Remove `www.`
62 | if (/^www\.(?:[a-z\-\d]{2,63})\.(?:[a-z.]{2,5})$/.test(urlObj.hostname)) {
63 | urlObj.hostname = urlObj.hostname.replace(/^www\./, '')
64 | }
65 | }
66 |
67 | urlObj.pathname = urlObj.pathname.replace(/\/$/, '')
68 |
69 | // Take advantage of many of the Node `url` normalizations
70 | urlString = urlObj.toString()
71 |
72 | // Remove ending `/`
73 | if (urlObj.pathname === '/') urlString = urlString.replace(/\/$/, '')
74 |
75 | urlString = urlString.replace(/^(?:https?:)?\/\//, '')
76 |
77 | return urlString
78 | }
79 |
80 | export default useFormat
81 |
--------------------------------------------------------------------------------
/src/hooks/meta.js:
--------------------------------------------------------------------------------
1 | import Head from 'next/head'
2 |
3 | const useMeta = (title, description, image, extra) => (
4 |
5 | {title ? `${title} - ` : ''}Auralite
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | {extra}
14 |
15 | )
16 |
17 | export const useTitle = (title, extra) => (
18 |
19 | {title ? `${title} - ` : ''}Auralite
20 |
21 |
22 | {extra}
23 |
24 | )
25 |
26 | export const useDescription = (description) => (
27 |
28 |
29 |
30 |
31 |
32 | )
33 |
34 | export const useImage = (image) => (
35 |
36 |
37 |
38 |
39 | )
40 |
41 | export default useMeta
42 |
--------------------------------------------------------------------------------
/src/hooks/notifications.js:
--------------------------------------------------------------------------------
1 | const { default: Client } = require('@/utils/Client')
2 | const { authCheck } = require('@/middleware/auth')
3 | const { default: useSWR } = require('swr')
4 |
5 | const useNotifications = (maybe = true) => {
6 | const { data, error, mutate } = useSWR(maybe && authCheck ? '/api/notifications' : null, () => Client.notifications())
7 | return {
8 | notifications: data,
9 | isLoading: !error && !data,
10 | isError: error,
11 | mutate,
12 | }
13 | }
14 |
15 | export default useNotifications
16 |
--------------------------------------------------------------------------------
/src/hooks/sticky.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 |
3 | const useStickyState = (defaultValue, key, shouldEncode = true) => {
4 | if (typeof window === 'undefined') {
5 | return useState(defaultValue)
6 | }
7 |
8 | const encode = shouldEncode ? (val) => JSON.stringify(val) : (val) => val
9 | const decode = shouldEncode ? (val) => JSON.parse(val) : (val) => val
10 |
11 | const [value, setValue] = useState(() => {
12 | const stickyValue = window.localStorage.getItem(key)
13 | return stickyValue !== null ? decode(stickyValue) : typeof defaultValue == 'function' ? defaultValue() : defaultValue
14 | })
15 |
16 | useEffect(() => {
17 | window.localStorage.setItem(key, encode(value))
18 | }, [key, value])
19 |
20 | return [value, setValue]
21 | }
22 |
23 | export default useStickyState
24 |
--------------------------------------------------------------------------------
/src/hooks/tailwind.js:
--------------------------------------------------------------------------------
1 | const scale = {
2 | px: '1px',
3 | 0: '0',
4 | 1: '0.25rem',
5 | 2: '0.5rem',
6 | 3: '0.75rem',
7 | 4: '1rem',
8 | 5: '1.25rem',
9 | 6: '1.5rem',
10 | 8: '2rem',
11 | 10: '2.5rem',
12 | 12: '3rem',
13 | 16: '4rem',
14 | 20: '5rem',
15 | 24: '6rem',
16 | 32: '8rem',
17 | 40: '10rem',
18 | 48: '12rem',
19 | 56: '14rem',
20 | 64: '16rem',
21 | }
22 |
23 | const useTailwind = (classes, type) => {
24 | if (!Array.isArray(type)) type = [type]
25 | classes = classes.split(' ')
26 |
27 | return type.map((key, i) => {
28 | const twClass = classes[i]
29 | const index = [...twClass.match(/^.*-(.*)$/)][1]
30 |
31 | if (!['width', 'height'].includes(key)) throw 'This hook only supports spacing variables'
32 |
33 | return scale[index]
34 | })
35 | }
36 |
37 | export default useTailwind
38 |
--------------------------------------------------------------------------------
/src/hooks/theme.js:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react'
2 | import ThemeContext from '@/context/theme'
3 |
4 | const useTheme = () => {
5 | const [theme, setTheme] = useContext(ThemeContext)
6 | const isDark = theme === 'dark'
7 | const isLight = theme === 'light'
8 | const toggleTheme = () => setTheme((theme) => (theme === 'dark' ? 'light' : 'dark'))
9 |
10 | return { theme, isDark, isLight, toggleTheme, setTheme }
11 | }
12 |
13 | export default useTheme
14 |
--------------------------------------------------------------------------------
/src/hooks/user.js:
--------------------------------------------------------------------------------
1 | import Client from '@/utils/Client'
2 | import { authCheck } from '@/middleware/auth'
3 | import useSWR from 'swr'
4 |
5 | const useUser = (maybe = true) => {
6 | const { data, error, mutate } = useSWR(maybe && authCheck ? '/api/user' : null, () => Client.user(), { revalidateOnFocus: false, focusThrottleInterval: 60000 })
7 |
8 | return {
9 | user: data,
10 | isLoading: !error && !data,
11 | isError: error,
12 | mutate,
13 | }
14 | }
15 |
16 | export default useUser
17 |
--------------------------------------------------------------------------------
/src/middleware/auth.js:
--------------------------------------------------------------------------------
1 | import redirectTo from '../utils/redirectTo'
2 | import Cookies from 'js-cookie'
3 |
4 | const AuthMiddleware = () => {
5 | if (!authCheck) return redirectTo('/login')
6 | }
7 |
8 | const GuestMiddleware = () => {
9 | if (authCheck) return redirectTo('/home')
10 | }
11 |
12 | export const authCheck = !!Cookies.get('auralite_token') || typeof window === 'undefined'
13 |
14 | const withAuth = () => [AuthMiddleware]
15 |
16 | export default withAuth
17 | export const withGuest = () => [GuestMiddleware]
18 |
--------------------------------------------------------------------------------
/src/pages/404.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Link from 'next/link'
3 | import { ThemeBubble } from '@/components/Global/ThemeManager'
4 | import { ArrowLeftOutline } from '@/components/App/Icon'
5 | import ClientOnly from '@/components/App/ClientOnly'
6 |
7 | const NotFoundPage = () => {
8 | return (
9 | <>
10 |
22 |
23 |
24 |
25 | >
26 | )
27 | }
28 |
29 | export default NotFoundPage
30 |
--------------------------------------------------------------------------------
/src/pages/[profile]/index.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react'
2 | import Client from '../../utils/Client'
3 | import useFormat from '../../hooks/format'
4 | import { usePageLayout } from '../../components/App/PageLayout'
5 | import { UploadableAvatar } from '../../components/App/Avatar'
6 | import Post from '../../components/App/Post'
7 | import Skeleton from '../../components/App/Skeleton'
8 | import useMeta from '../../hooks/meta'
9 | import { useRouter } from 'next/router'
10 | import useAlert from '@/hooks/alert'
11 | import useUser from '@/hooks/user'
12 | import Tabs from '@/components/App/Tabs'
13 |
14 | const Profile = ({ handle, authCheck, profile }) => {
15 | const router = useRouter()
16 | const isReplies = router.pathname.endsWith('/replies')
17 |
18 | const { user: currentUser, mutate: mutateUser } = useUser()
19 |
20 | const setMeta = useMeta(profile && `${profile?.name} (@${handle})`, profile?.bio, `/api/meta/profile?handle=${handle}`, )
21 | const userBio = useFormat(profile?.bio)
22 | const [error, setError] = useState(null)
23 | const [isUpdating, setIsUpdating] = useState(false)
24 | const [bio, setBio] = useState(profile?.bio)
25 | const [avatar, setAvatar] = useState(null)
26 | const { createAlert } = useAlert()
27 |
28 | useEffect(() => {
29 | setBio(profile?.bio)
30 | }, [profile])
31 |
32 | const saveChanges = () => {
33 | Client.updateProfile({ bio, avatar })
34 | .then((profile) => {
35 | setIsUpdating(false)
36 | setAvatar(null)
37 | setError(null)
38 |
39 | mutateUser((user) => {
40 | user.profile = profile
41 |
42 | return user
43 | })
44 |
45 | createAlert({ title: 'Profile Updated', body: 'Your profile has been updated. Changes might take a few seconds to propagate.' })
46 | })
47 | .catch((error) => {
48 | if (!error.response?.data?.errors) return alert('Something went wrong when updating your profile.')
49 |
50 | setError(error.response.data.errors.bio[0])
51 | })
52 | }
53 |
54 | const removeFromProfile = (deletedPost) => {
55 | const profileFunc = (profile) => {
56 | profile.posts = profile.posts.filter((post) => post.id !== deletedPost.id)
57 |
58 | return profile
59 | }
60 |
61 | mutateUser((user) => {
62 | user.profile = profileFunc(user.profile)
63 |
64 | return user
65 | })
66 | }
67 |
68 | return (
69 | <>
70 | {setMeta}
71 |
72 |
73 |
74 |
75 |
76 | setAvatar(key)} />
77 | {profile && currentUser && profile.handle === currentUser.profile.handle && (
78 | <>
79 | {isUpdating ? (
80 |
81 | Save Changes
82 |
83 | ) : (
84 | setIsUpdating((state) => !state)} type="button" className="inline-flex items-center px-2.5 py-1.5 border-2 border-indigo-500 text-sm leading-5 font-medium rounded-md text-indigo-500 hover:bg-indigo-500 hover:text-indigo-50 dark-hover:bg-indigo-600 dark-hover:bg-opacity-25 dark-hover:border-transparent focus:outline-none focus:shadow-outline-indigo transition ease-in-out duration-150">
85 | Edit Profile
86 |
87 | )}
88 | >
89 | )}
90 |
91 |
{profile?.name ? profile.name : }
92 |
{profile?.handle ? `@${profile.handle}` : }
93 |
94 | {isUpdating ? (
95 | <>
96 |
97 |
setBio(event.target.value)} value={bio} required minLength="60" maxLength="160" />
98 |
99 |
100 | 160 ? 'text-red-400 dark:text-red-500' : 'text-green-400 dark:text-green-500'}>{bio.length} /{bio.length < 60 ? 60 : 160}
101 |
102 |
103 |
104 | {error &&
{error}
}
105 | >
106 | ) : (
107 |
{userBio[0] ? userBio : }
108 | )}
109 |
110 |
111 |
112 |
113 |
114 |
115 |
121 |
122 |
123 |
124 |
{profile ? profile.posts.filter((post) => (isReplies ? post.reply_to : !post.reply_to)).map((post) =>
) : [...Array(10).keys()].map((key) =>
)}
125 |
126 |
127 | >
128 | )
129 | }
130 |
131 | Profile.getLayout = usePageLayout()
132 |
133 | export const getStaticProps = async ({ params: { profile } }) => {
134 | try {
135 | return {
136 | props: {
137 | handle: profile,
138 | profile: await Client.profile({ handle: profile }),
139 | },
140 | revalidate: 1,
141 | }
142 | } catch (error) {
143 | return { props: { isError: true, statusCode: error.response.status }, revalidate: 1 }
144 | }
145 | }
146 |
147 | export const getStaticPaths = async () => {
148 | return {
149 | paths: [{ params: { profile: 'miguel' } }],
150 | fallback: true,
151 | }
152 | }
153 |
154 | export default Profile
155 |
--------------------------------------------------------------------------------
/src/pages/[profile]/posts/[post].js:
--------------------------------------------------------------------------------
1 | import { usePageLayout } from '../../../components/App/PageLayout'
2 | import { useRouter } from 'next/router'
3 | import Client from '../../../utils/Client'
4 | import useMeta from '../../../hooks/meta'
5 | import Compose from '../../../components/App/Compose'
6 | import Post from '../../../components/App/Post'
7 | import { useEffect, useRef, useLayoutEffect } from 'react'
8 | import { authCheck } from '@/middleware/auth'
9 | import useSWR from 'swr'
10 | import { isSSR } from '@/components/App/ClientOnly'
11 |
12 | const PostPage = ({ postId, post: initialData }) => {
13 | const router = useRouter()
14 | const postRef = useRef(null)
15 | const onServer = isSSR()
16 | const { data: post, mutate } = useSWR(`/posts/${postId}`, () => Client.post({ postId }), { initialData })
17 |
18 | const setMeta = useMeta(post && `${post.author.name} (@${post.author_handle}) on Auralite`, post?.content, `/api/meta/post?postId=${postId}`)
19 |
20 | const newPost = (newPost) => {
21 | mutate((post) => {
22 | post.replies.push(newPost)
23 |
24 | return post
25 | })
26 | }
27 |
28 | const onReplyDelete = (deletedPost) => {
29 | mutate((post) => {
30 | post.replies.filter((reply) => reply.id !== deletedPost.id)
31 |
32 | return post
33 | })
34 | }
35 |
36 | const scrollToReply = () => {
37 | window.requestAnimationFrame(() => {
38 | setTimeout(() => {
39 | window.scroll({ top: postRef.current?.offsetTop })
40 | }, 200)
41 | })
42 | }
43 |
44 | useEffect(() => {
45 | router.events.on('routeChangeComplete', () => scrollToReply())
46 |
47 | return () => {
48 | router.events.off('routeChangeComplete', () => scrollToReply())
49 | }
50 | }, [])
51 |
52 | const call = onServer ? useEffect : useLayoutEffect
53 |
54 | call(() => {
55 | scrollToReply()
56 | }, [postRef])
57 |
58 | return (
59 | <>
60 | {setMeta}
61 |
62 |
63 |
64 |
router.back()} withBorder={false} />
65 |
66 |
67 | {authCheck &&
}
68 |
{post ? post.replies.map((reply, key) =>
) : [...Array(3).keys()].map((key) =>
)}
69 |
70 |
71 |
72 | >
73 | )
74 | }
75 |
76 | PostPage.getLayout = usePageLayout()
77 |
78 | export const getStaticProps = async ({ params: { profile, post } }) => {
79 | try {
80 | return {
81 | props: {
82 | postId: post,
83 | handle: profile,
84 | post: await Client.post({ postId: post }),
85 | },
86 | revalidate: 1,
87 | }
88 | } catch (error) {
89 | return { props: { isError: true, statusCode: error.response.status }, revalidate: 1 }
90 | }
91 | }
92 |
93 | export const getStaticPaths = async () => ({
94 | paths: [],
95 | fallback: true,
96 | })
97 |
98 | export default PostPage
99 |
--------------------------------------------------------------------------------
/src/pages/[profile]/replies.js:
--------------------------------------------------------------------------------
1 | export { default, getStaticProps, getStaticPaths } from './index'
2 |
--------------------------------------------------------------------------------
/src/pages/_app.js:
--------------------------------------------------------------------------------
1 | import '../../src/scss/app.scss'
2 | import 'nprogress/nprogress.css'
3 | import { useEffect } from 'react'
4 | import * as Fathom from 'fathom-client'
5 | import * as Sentry from '@sentry/browser'
6 | import NProgress from 'nprogress'
7 | import Error from './_error'
8 | import { useBaseLayout } from '@/components/Global/BaseLayout'
9 |
10 | Sentry.init({
11 | enabled: process.env.NODE_ENV === 'production',
12 | dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
13 | })
14 |
15 | const MyApp = ({ Component, pageProps, router, ...serverProps }) => {
16 | useEffect(() => {
17 | Fathom.load('KXUEDJON', {
18 | includedDomains: ['auralite.io'],
19 | url: 'https://koi.auralite.io/script.js',
20 | })
21 |
22 | const onRouteChangeComplete = () => Fathom.trackPageview()
23 |
24 | router.events.on('routeChangeComplete', onRouteChangeComplete)
25 |
26 | return () => {
27 | router.events.off('routeChangeComplete', onRouteChangeComplete)
28 | }
29 | }, [])
30 |
31 | useEffect(() => {
32 | const startProgress = () => NProgress.start()
33 | const progressEnd = () => NProgress.done()
34 |
35 | router.events.on('routeChangeStart', startProgress)
36 | router.events.on('routeChangeComplete', progressEnd)
37 | router.events.on('routeChangeError', progressEnd)
38 |
39 | return () => {
40 | router.events.off('routeChangeStart', startProgress)
41 | router.events.off('routeChangeComplete', progressEnd)
42 | router.events.off('routeChangeError', progressEnd)
43 | }
44 | }, [])
45 |
46 | if (pageProps?.isError) return
47 |
48 | const getLayout = Component.getLayout || useBaseLayout()
49 |
50 | return getLayout( , { ...serverProps, middleware: Component.middleware })
51 | }
52 |
53 | export default MyApp
54 |
--------------------------------------------------------------------------------
/src/pages/_document.js:
--------------------------------------------------------------------------------
1 | import Document, { Html, Head, Main, NextScript } from 'next/document'
2 |
3 | class MyDocument extends Document {
4 | static async getInitialProps(ctx) {
5 | const initialProps = await Document.getInitialProps(ctx)
6 | return { ...initialProps }
7 | }
8 |
9 | render() {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | )
19 | }
20 | }
21 |
22 | export default MyDocument
23 |
--------------------------------------------------------------------------------
/src/pages/_error.js:
--------------------------------------------------------------------------------
1 | import NextErrorComponent from 'next/error'
2 | import * as Sentry from '@sentry/node'
3 |
4 | const MyError = ({ statusCode, hasGetInitialPropsRun, err }) => {
5 | if (!hasGetInitialPropsRun && err) {
6 | // getInitialProps is not called in case of
7 | // https://github.com/vercel/next.js/issues/8592. As a workaround, we pass
8 | // err via _app.js so it can be captured
9 | Sentry.captureException(err)
10 | }
11 |
12 | return (
13 |
14 |
15 |
16 | )
17 | }
18 |
19 | MyError.getInitialProps = async ({ res, err }) => {
20 | const errorInitialProps = await NextErrorComponent.getInitialProps({
21 | res,
22 | err,
23 | })
24 |
25 | // Workaround for https://github.com/vercel/next.js/issues/8592, mark when
26 | // getInitialProps has run
27 | errorInitialProps.hasGetInitialPropsRun = true
28 |
29 | // Running on the server, the response object (`res`) is available.
30 | //
31 | // Next.js will pass an err on the server if a page's data fetching methods
32 | // threw or returned a Promise that rejected
33 | //
34 | // Running on the client (browser), Next.js will provide an err if:
35 | //
36 | // - a page's `getInitialProps` threw or returned a Promise that rejected
37 | // - an exception was thrown somewhere in the React lifecycle (render,
38 | // componentDidMount, etc) that was caught by Next.js's React Error
39 | // Boundary. Read more about what types of exceptions are caught by Error
40 | // Boundaries: https://reactjs.org/docs/error-boundaries.html
41 |
42 | if (res?.statusCode === 404) return { statusCode: 404 }
43 |
44 | if (err) {
45 | Sentry.captureException(err)
46 | return errorInitialProps
47 | }
48 |
49 | return errorInitialProps
50 | }
51 |
52 | export default MyError
53 |
--------------------------------------------------------------------------------
/src/pages/api/auth/login.js:
--------------------------------------------------------------------------------
1 | import Client from '../../../utils/Client'
2 |
3 | export default (req, res) => {
4 | if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' })
5 |
6 | const { email, password } = req.body
7 |
8 | Client.login({ email, password })
9 | .then((data) => res.status(200).json(data))
10 | .catch((error) => res.status(error.response.status).json(error.response.data))
11 | }
12 |
--------------------------------------------------------------------------------
/src/pages/api/auth/register.js:
--------------------------------------------------------------------------------
1 | import Client from '../../../utils/Client'
2 |
3 | export default (req, res) => {
4 | if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' })
5 |
6 | const { email, password, code } = req.body
7 |
8 | Client.register({ email, password, code })
9 | .then((data) => res.status(200).json(data))
10 | .catch((error) => res.status(error.response.status).json(error.response.data))
11 | }
12 |
--------------------------------------------------------------------------------
/src/pages/api/meta/post.js:
--------------------------------------------------------------------------------
1 | import { getScreenshot } from '@/utils/puppeteer'
2 |
3 | export default async (req, res) => {
4 | if (req.method !== 'GET' || !req.query.postId) return res.status(400).json({ error: 'Invalid Request' })
5 |
6 | res.setHeader('Content-Type', `image/jpeg`)
7 | res.setHeader('Cache-Control', `public, immutable, no-transform, s-maxage=604800, stale-while-revalidate`)
8 |
9 | res.status(200).send(await getScreenshot(`http://auralite.io/meta/post?postId=${req.query.postId}`))
10 | }
11 |
--------------------------------------------------------------------------------
/src/pages/api/meta/profile.js:
--------------------------------------------------------------------------------
1 | import { getScreenshot } from '@/utils/puppeteer'
2 |
3 | export default async (req, res) => {
4 | if (req.method !== 'GET' || !req.query.handle) return res.status(400).json({ error: 'Invalid Request' })
5 |
6 | res.setHeader('Content-Type', `image/jpeg`)
7 | res.setHeader('Cache-Control', `public, immutable, no-transform, s-maxage=3600, stale-while-revalidate`)
8 |
9 | res.status(200).send(await getScreenshot(`https://auralite.io/meta/profile?handle=${req.query.handle}`))
10 | }
11 |
--------------------------------------------------------------------------------
/src/pages/code-of-conduct.mdx:
--------------------------------------------------------------------------------
1 | export const meta = {
2 | title: 'Code of Conduct',
3 | description: `The Auralite Code of Conduct`,
4 | date: '2020-07-15T04:45:30Z',
5 | }
6 |
7 | All Auralite users are expected to abide by our Code of Conduct, both on the platform and on any Auralite-related site.
8 |
9 | ## Our Pledge
10 |
11 | In the interest of fostering an open and welcoming environment, we as moderators of Auralite pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
12 |
13 | ## Our Standards
14 | Examples of behavior that contributes to creating a positive environment include:
15 |
16 | - Using _welcoming_ and _inclusive_ language
17 | - Being _respectful_ of differing viewpoints and experiences
18 | - Referring to people by their preferred pronouns and **using gender-neutral pronouns when uncertain**
19 | - Gracefully accepting _constructive criticism_
20 | - Focusing on what is best for the community
21 | - Showing _empathy_ towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | - The use of sexualized language or imagery and unwelcome sexual attention or advances
26 | - Trolling, insulting/derogatory comments, and personal or political attacks
27 | - Public or private harassment
28 | - Publishing others' private information, such as a physical or electronic address, without explicit permission
29 | - Other conduct which could reasonably be considered inappropriate in a professional setting
30 | - Dismissing or attacking inclusion-oriented requests
31 |
32 | We pledge to prioritize marginalized people’s safety over privileged people’s comfort. We will not act on complaints regarding:
33 |
34 | - ‘Reverse’ -isms, including ‘reverse racism,’ ‘reverse sexism,’ and ‘cisphobia’
35 | - Reasonable communication of boundaries, such as 'leave me alone,' 'go away,' or 'I’m not discussing this with you.'
36 | - Someone’s refusal to explain or debate social justice concepts
37 | - Criticisms of racist, sexist, cissexist, or otherwise oppressive behavior or assumptions
38 |
39 | ## Enforcement
40 |
41 | Violations of the Code of Conduct may be reported by sending an email to [mod@auralite.io](mailto:mod@auralite.io). We also plan to develop easier ways to report posts from the platform in the near future. All reports will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Further details of specific enforcement policies may be posted separately.
42 |
43 | Moderators have the right and responsibility to remove comments or other contributions that are not aligned to this Code of Conduct, or to suspend temporarily or permanently any members for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
44 |
45 | ## Attribution
46 |
47 | This Code of Conduct is heavily inspired on the [DEV Code of Conduct](https://dev.to/code-of-conduct), which is in turn adapted from:
48 |
49 | - [Contributor Covenant, version 1.4](https://www.contributor-covenant.org/version/1/4/)
50 | - [Write/Speak/Code](http://www.writespeakcode.com/code-of-conduct.html)
51 | - [Geek Feminism](https://geekfeminism.org/about/code-of-conduct)
52 |
53 | If you have any feedback or suggestions regarding this code of conduct, you're very welcome to [reach out](mailto:miguel@auralite.io).
--------------------------------------------------------------------------------
/src/pages/login.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react'
2 | import Logo from '../components/Global/Logo'
3 | import Link from 'next/link'
4 | import axios from 'axios'
5 | import { useRouter } from 'next/router'
6 | import { withGuest } from '../middleware/auth'
7 | import { login } from '@/utils/auth'
8 |
9 | const Login = () => {
10 | const router = useRouter()
11 | const [error, setError] = useState(null)
12 | const [email, setEmail] = useState('')
13 | const [password, setPassword] = useState('')
14 |
15 | useEffect(() => {
16 | router.prefetch('/home')
17 | }, [])
18 |
19 | const loginUser = (event) => {
20 | event.preventDefault()
21 |
22 | setError(null)
23 |
24 | axios
25 | .post('/api/auth/login', { email, password })
26 | .then((response) => {
27 | login(response.data.access_token)
28 |
29 | window.location = '/home'
30 | })
31 | .catch((error) => {
32 | if (error.response.data.error !== 'invalid_grant') return alert('Something went wrong! Please try again or contact us if the problem persists.')
33 |
34 | setError('These credentials do not match our records.')
35 | })
36 | }
37 |
38 | useEffect(() => setError(null), [email, password])
39 |
40 | return (
41 |
42 |
43 |
53 |
54 |
62 | {error && {error}
}
63 |
64 |
65 |
66 |
67 |
68 | Remember me
69 |
70 |
71 |
72 |
73 | alert('nah')} className="font-medium text-indigo-600 hover:text-indigo-500 focus:outline-none focus:underline transition ease-in-out duration-150">
74 | Forgot your password?
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 | Sign in
87 |
88 |
89 |
90 |
91 |
92 | )
93 | }
94 |
95 | Login.middleware = withGuest()
96 |
97 | export default Login
98 |
--------------------------------------------------------------------------------
/src/pages/meta/post.js:
--------------------------------------------------------------------------------
1 | import Client from '@/utils/Client'
2 | import Avatar from '@/components/App/Avatar'
3 | import useFormat from '@/hooks/format'
4 | import Logo from '@/components/Global/Logo'
5 |
6 | const PostMeta = ({ post }) => {
7 | const postContent = useFormat(post.content, { underlineLinks: true })
8 |
9 | return (
10 |
11 |
12 |
13 |
14 |
23 |
24 |
33 |
34 |
35 |
40 |
41 |
42 |
43 | Auralite
44 |
45 |
46 | @{post.author.handle}
47 |
48 |
49 | {postContent}
50 |
51 | {post.media &&
52 | post.media.map((_, key) => (
53 |
54 |
55 |
56 | ))}
57 |
58 |
59 |
60 |
61 |
62 | )
63 | }
64 |
65 | export const getServerSideProps = async ({ query }) => {
66 | try {
67 | return { props: { post: await Client.post({ postId: query.postId }) } }
68 | } catch (error) {
69 | return { props: { isError: true, statusCode: error.response.status } }
70 | }
71 | }
72 |
73 | export default PostMeta
74 |
--------------------------------------------------------------------------------
/src/pages/meta/profile.js:
--------------------------------------------------------------------------------
1 | import Client from '@/utils/Client'
2 | import Avatar from '@/components/App/Avatar'
3 | import useFormat from '@/hooks/format'
4 | import Logo from '@/components/Global/Logo'
5 |
6 | const ProfileMeta = ({ profile }) => {
7 | const userBio = useFormat(profile?.bio)
8 |
9 | return (
10 |
11 |
12 |
13 |
14 |
23 |
24 |
33 |
34 |
35 |
40 |
41 |
42 |
43 | Auralite
44 |
45 |
46 | @{profile.handle}
47 |
48 |
49 | {profile.name}
50 |
51 |
{userBio}
52 |
53 |
54 |
55 | )
56 | }
57 |
58 | export const getServerSideProps = async ({ query }) => {
59 | try {
60 | return { props: { profile: await Client.profile({ handle: query.handle }) } }
61 | } catch (error) {
62 | return { props: { isError: true, statusCode: error.response.status } }
63 | }
64 | }
65 |
66 | export default ProfileMeta
67 |
--------------------------------------------------------------------------------
/src/pages/notifications.js:
--------------------------------------------------------------------------------
1 | import { usePageLayout } from '../components/App/PageLayout'
2 | import Notification from '../components/App/Notification'
3 | import { useTitle } from '../hooks/meta'
4 | import withAuth from '../middleware/auth'
5 | import { useState, useMemo } from 'react'
6 | import { ZERO_TAGLINES } from '@/utils/constants'
7 | import { random } from '@/utils/arr'
8 | import useNotifications from '@/hooks/notifications'
9 | import Tabs from '@/components/App/Tabs'
10 | import Swipe from 'react-easy-swipe'
11 |
12 | const Notifications = () => {
13 | const setTitle = useTitle('Notifications')
14 | const { notifications } = useNotifications()
15 | const [showRead, setShowRead] = useState(false)
16 | const [swipeDirection, setSwipeDirection] = useState(null)
17 | const filteredNotifications = notifications?.filter((notification) => showRead || notification.unread)
18 | const zeroTagline = useMemo(() => random(ZERO_TAGLINES), [ZERO_TAGLINES])
19 |
20 | const registerDirection = (position) => {
21 | if (Math.abs(position.x) < 50) return
22 |
23 | setSwipeDirection(position.x > 0 ? 'right' : 'left')
24 | }
25 |
26 | const performSwipe = () => {
27 | if (swipeDirection === 'left' && !showRead) setShowRead(true)
28 | if (swipeDirection === 'right' && showRead) setShowRead(false)
29 |
30 | setSwipeDirection(null)
31 | }
32 |
33 | return (
34 | <>
35 | {setTitle}
36 |
37 |
38 |
39 |
40 |
41 | setShowRead(false),
47 | content: (
48 | <>
49 | {notifications && {notifications.filter((notification) => notification.unread).length}
}
50 | Unread
51 | >
52 | ),
53 | },
54 | {
55 | tag: 'button',
56 | active: showRead,
57 | onClick: () => setShowRead(true),
58 | content: All ,
59 | },
60 | ]}
61 | />
62 |
63 |
64 |
65 | {notifications ? filteredNotifications.map((notification, key) =>
) : [...Array(10).keys()].map((key) =>
)}
66 | {notifications && filteredNotifications.length === 0 && (
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
No new notifications
77 |
{zeroTagline}
78 |
79 | )}
80 |
81 |
82 | >
83 | )
84 | }
85 |
86 | Notifications.middleware = withAuth()
87 | Notifications.getLayout = usePageLayout('Notifications')
88 |
89 | export default Notifications
90 |
--------------------------------------------------------------------------------
/src/pages/onboarding/identity.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react'
2 | import Logo from '@/components/Global/Logo'
3 | import LoadingButton from '@/components/App/LoadingButton'
4 | import Client from '@/utils/Client'
5 | import { useRouter } from 'next/router'
6 | import withAuth from '@/middleware/auth'
7 | import useUser from '@/hooks/user'
8 |
9 | const Verify = () => {
10 | const router = useRouter()
11 | const { user } = useUser
12 | const isWaiting = user?.profile?.verification_status === 'IN_PROGRESS'
13 | const [isLoading, setIsLoading] = useState(false)
14 | const startVerification = (event) => {
15 | event.preventDefault()
16 |
17 | setIsLoading(true)
18 |
19 | Client.startIdentityVerification()
20 | }
21 |
22 | useEffect(() => {
23 | if (!isWaiting) return
24 |
25 | setIsLoading(true)
26 | }, [isWaiting])
27 |
28 | return (
29 |
30 |
31 |
32 |
{isWaiting ? "We're now verifying your identity." : 'Verify your identity'}
33 | {router.query.error && (
34 |
35 |
36 |
41 |
42 |
We had some issues while verifying your identity
43 |
44 | {router.query.error === 'consent_declined' &&
You need to consent to the verification to proceed
}
45 | {router.query.error === 'unverified' &&
We were unable to verify your identity. Please try again or contact us if the issue persists.
}
46 | {router.query.error === 'name_check_failed' &&
We were able to verify your identity, but couldn't verify your name matches the one on your profile. Please contact us to resolve this issue.
}
47 |
48 |
49 |
50 |
51 | )}
52 | {isWaiting ?
Feel free to close this tab and go on with your day, we'll make sure to email you when we're done.
:
We want to make Auralite about people and, to do so, we require you to verify your identity.
}
53 | {isWaiting || (
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | Start Verification
62 |
63 |
64 | )}
65 |
66 |
67 | )
68 | }
69 |
70 | Verify.middleware = withAuth()
71 |
72 | export default Verify
73 |
--------------------------------------------------------------------------------
/src/pages/onboarding/profile.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import withAuth from '@/middleware/auth'
3 | import Client from '@/utils/Client'
4 | import Logo from '@/components/Global/Logo'
5 | import { UploadableAvatar } from '@/components/App/Avatar'
6 | import { handleValidationErrors } from '@/utils/errors'
7 |
8 | const CreateProfile = () => {
9 | const [errors, setErrors] = useState({})
10 |
11 | const [firstName, setFirstName] = useState('')
12 | const [lastName, setLastName] = useState('')
13 | const [handle, setHandle] = useState('')
14 | const [bio, setBio] = useState('')
15 | const [avatar, setAvatar] = useState()
16 |
17 | const submitForm = (event) => {
18 | event.preventDefault()
19 |
20 | Client.createProfile({ name: `${firstName} ${lastName}`, handle, bio, avatar }).catch((error) => handleValidationErrors(error, setErrors))
21 | }
22 |
23 | return (
24 |
25 |
26 |
27 |
Create your profile
28 |
To get started with Auralite, fill in your profile.
29 |
30 |
31 |
32 |
33 |
34 |
Profile
35 |
This information will be displayed publicly so be careful what you share.
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | First Name
46 |
47 |
48 |
setFirstName(event.target.value)} required />
49 | {errors.name && (
50 |
55 | )}
56 |
57 |
58 |
59 |
60 | Last Name
61 |
62 |
63 |
setLastName(event.target.value)} required />
64 | {errors.name && (
65 |
70 | )}
71 |
72 |
73 |
74 |
{errors.name ? errors.name[0] : "We want to create a space for human interaction, so we ask you to please use your real name. You'll need to verify this before posting."}
75 |
76 |
77 |
78 | Handle
79 |
80 |
81 |
82 | @
83 |
84 |
setHandle(event.target.value)} required minLength="2" maxLength="255" pattern="^[a-zA-Z_]{1}\w{1,14}$" />
85 | {errors.handle && (
86 |
91 | )}
92 |
93 |
{errors.handle ? errors.handle[0] : 'This can not be changed, so pick something cool!'}
94 |
95 |
96 |
97 |
98 | Bio
99 |
100 |
101 |
setBio(event.target.value)} value={bio} required minLength="60" maxLength="160" />
102 |
103 |
104 | 160 ? 'text-red-400' : 'text-green-400'}>{bio.length} /{bio.length < 60 ? 60 : 160}
105 |
106 |
107 |
108 |
{errors.bio ? errors.bio[0] : 'Brief description for your profile. URLs are hyperlinked. Min 60 chars.'}
109 |
110 |
111 |
112 |
113 | Avatar
114 |
115 |
116 | setAvatar(key)} sizeClasses="h-12 w-12" />
117 |
118 |
{errors.avatar ? errors.avatar[0] : "We require you to have an avatar so others can recognize you. Don't worry, you can change it from your profile later!"}
119 |
120 |
121 |
122 |
123 |
124 | Create
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 | )
135 | }
136 |
137 | CreateProfile.middleware = withAuth()
138 |
139 | export default CreateProfile
140 |
--------------------------------------------------------------------------------
/src/pages/onboarding/subscription.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { loadStripe } from '@stripe/stripe-js/pure'
3 | import Client from '@/utils/Client'
4 | import Logo from '@/components/Global/Logo'
5 | import withAuth from '@/middleware/auth'
6 |
7 | const Subscription = () => {
8 | const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_KEY)
9 |
10 | const [plan, setPlan] = useState('yearly')
11 |
12 | const launchCheckout = async () => {
13 | const sessionId = await Client.launchCheckout({ plan }).catch(() => alert('Something went wrong when creating your subscription. Please reload the page and try again, or contact us if the problem persists.'))
14 |
15 | const stripe = await stripePromise
16 |
17 | const { error } = await stripe.redirectToCheckout({ sessionId })
18 |
19 | if (error) alert(error)
20 | }
21 |
22 | return (
23 |
24 |
25 |
26 |
27 |
Subscribe to Auralite
28 |
29 |
30 |
31 |
36 |
37 |
Since you're gonna be helping test Auralite while it's still in beta, we'll apply a 50% discount to your account. Have fun!
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
{plan === 'yearly' ? 'Yearly' : 'Monthly'} Membership
46 |
Ad-funded platforms focus on the advertisers who are generating money. With an user-funded platform we can focus exclusively on improving your experience.
47 |
48 |
49 |
What's included
50 |
51 |
52 |
53 |
54 |
59 | No ads, ever
60 |
61 |
62 |
67 | A voice on the development of Auralite
68 |
69 |
70 |
75 | Total control over your privacy
76 |
77 |
78 |
83 | Unlimited access to Auralite, everything included!
84 |
85 |
86 |
87 |
88 |
89 |
Pay once, enjoy all {plan === 'yearly' ? 'year' : 'month'}
90 |
91 | ${plan === 'yearly' ? '99' : '10'}
92 | / {plan === 'yearly' ? 'yr' : 'month'}
93 |
94 |
95 |
96 |
97 | Start 7-day trial
98 |
99 |
100 |
101 |
102 | setPlan(plan === 'yearly' ? 'monthly' : 'yearly')} className="font-medium text-gray-700 underline focus:outline-none focus:text-gray-900 hover:text-gray-900 transition duration-100 ease-in-out">
103 | I'd rather pay {plan === 'yearly' ? 'monthly' : 'yearly'}
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 | )
113 | }
114 |
115 | Subscription.middleware = withAuth()
116 |
117 | export default Subscription
118 |
--------------------------------------------------------------------------------
/src/pages/onboarding/verify.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react'
2 | import Client from '@/utils/Client'
3 | import Logo from '@/components/Global/Logo'
4 | import useAlert from '@/hooks/alert'
5 | import { useBaseLayout } from '@/components/Global/BaseLayout'
6 | import withAuth from '@/middleware/auth'
7 |
8 | const VerifyEmail = () => {
9 | const [status, setStatus] = useState(null)
10 | const { createAlert } = useAlert()
11 |
12 | useEffect(() => {
13 | if (!status) return
14 |
15 | createAlert({
16 | title: 'Verification Resent',
17 | body: status,
18 | })
19 | }, [status])
20 |
21 | const resendVerification = (event) => {
22 | event.preventDefault()
23 |
24 | Client.resendEmailVerification().then(() => setStatus('A fresh verification link has been sent to your email address.'))
25 | }
26 |
27 | return (
28 |
29 |
30 |
31 |
32 |
Verify your email
33 |
We've sent you an email with a verification link. Please click it to start creating your Auralite profile.
34 | {!status && (
35 |
36 |
37 | Didn't get it? Resend.
38 |
39 |
40 | )}
41 |
42 |
43 |
44 | )
45 | }
46 |
47 | VerifyEmail.middleware = withAuth()
48 | VerifyEmail.getLayout = useBaseLayout()
49 |
50 | export default VerifyEmail
51 |
--------------------------------------------------------------------------------
/src/pages/register.js:
--------------------------------------------------------------------------------
1 | import { withGuest } from '@/middleware/auth'
2 | import { useBaseLayout } from '@/components/Global/BaseLayout'
3 | import { useState } from 'react'
4 | import Logo from '@/components/Global/Logo'
5 | import Link from 'next/link'
6 | import LoadingButton from '@/components/App/LoadingButton'
7 | import { handleValidationErrors } from '@/utils/errors'
8 | import axios from 'axios'
9 | import { login } from '@/utils/auth'
10 | import { useRouter } from 'next/router'
11 |
12 | const Register = () => {
13 | const router = useRouter()
14 |
15 | const [loading, setLoading] = useState(false)
16 | const [errors, setErrors] = useState({})
17 | const [email, setEmail] = useState('')
18 | const [password, setPassword] = useState('')
19 | const [code, setCode] = useState('')
20 |
21 | const registerUser = (e) => {
22 | e.preventDefault()
23 |
24 | setLoading(true)
25 |
26 | axios
27 | .post('/api/auth/register', { email, password, code })
28 | .then((response) => {
29 | login(response.data.access_token)
30 |
31 | router.push('/home')
32 | })
33 | .catch((error) => {
34 | setLoading(false)
35 |
36 | handleValidationErrors(error, setErrors)
37 | })
38 | }
39 |
40 | return (
41 |
42 |
43 |
53 |
54 |
55 |
56 | setEmail(event.target.value)} required />
57 |
58 |
59 | {errors.email && {errors.email[0]}
}
60 |
61 |
62 |
63 | setPassword(event.target.value)} required />
64 |
65 |
66 | {errors.password && {errors.password[0]}
}
67 |
68 |
69 |
70 | setCode(event.target.value)} required />
71 |
72 |
73 | {errors.code && (
74 |
75 | Invite codes are required until public launch. If you don't have one, and you want one,{' '}
76 |
77 | click here
78 |
79 | .
80 |
81 | )}
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 | Get Started
91 |
92 |
93 |
94 |
95 |
96 | )
97 | }
98 |
99 | Register.getLayout = useBaseLayout()
100 |
101 | Register.middleware = withGuest()
102 |
103 | export default Register
104 |
--------------------------------------------------------------------------------
/src/pages/search.js:
--------------------------------------------------------------------------------
1 | import { usePageLayout } from '../components/App/PageLayout'
2 | import withAuth from '../middleware/auth'
3 | import { useRouter } from 'next/router'
4 | import { useSWRInfinite } from 'swr'
5 | import Client from '@/utils/Client'
6 | import Post from '@/components/App/Post'
7 | import { useEffect, useState, useCallback } from 'react'
8 | import { useInView } from 'react-intersection-observer'
9 | import { SearchOutline } from '@/components/App/Icon'
10 |
11 | const Search = () => {
12 | const router = useRouter()
13 |
14 | const [query, setQuery] = useState(router.query.q ?? '')
15 |
16 | const { data, isLoading, loadMore, isReachingEnd, isEmpty } = useSearchResults(query)
17 |
18 | const [$timelineEnd, isEnd] = useInView({ threshold: 1, rootMargin: '200px' })
19 |
20 | useEffect(() => {
21 | if (isEnd) loadMore()
22 | }, [isEnd])
23 |
24 | useEffect(() => {
25 | router.push({
26 | pathname: router.pathname,
27 | query: { q: encodeURI(query) },
28 | })
29 | }, [query])
30 |
31 | return (
32 | <>
33 |
34 |
35 |
36 |
37 |
38 |
39 | {/*eslint-disable-next-line jsx-a11y/no-autofocus */}
40 |
setQuery(event.target.value)} className="form-input dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600 block w-full pl-10 sm:text-sm sm:leading-5" placeholder="Search Auralite" autoFocus={true} />
41 |
42 |
43 |
{data}
44 | {isLoading && (
45 |
46 | {[...Array(10).keys()].map((key) => (
47 |
48 | ))}
49 |
50 | )}
51 | {!isReachingEnd &&
}
52 | {isReachingEnd && !isEmpty &&
No more results :(
}
53 | {isEmpty &&
No results :(
}
54 |
55 | >
56 | )
57 | }
58 |
59 | const useSearchResults = (query) => {
60 | const { data, error, mutate, size, setSize } = useSWRInfinite(
61 | (index, previousPageData) => {
62 | if (previousPageData && previousPageData.currentPage === previousPageData.lastPage) return null
63 |
64 | return `/api/search?q=${query}&page=${(previousPageData?.currentPage ?? index) + 1}`
65 | },
66 | async (key) => {
67 | const data = await Client.search({ query, page: key.split('?page=', 2)[1] })
68 |
69 | if (!data) return
70 |
71 | return { currentPage: data.current_page, lastPage: data.last_page, posts: data.data.map((post) => mutate()} />) }
72 | }
73 | )
74 |
75 | const isLoading = (!data && !error) || (data && typeof data[size - 1] === 'undefined')
76 | const isReachingEnd = data?.[size - 1]?.currentPage === data?.[size - 1]?.lastPage
77 | const loadMore = useCallback(async () => setSize((size) => size + 1), [])
78 |
79 | return { data: data?.map((page) => page.posts), isLoading, loadMore, isReachingEnd, refresh: mutate }
80 | }
81 |
82 | Search.getLayout = usePageLayout('Search')
83 | Search.middleware = withAuth()
84 |
85 | export default Search
86 |
--------------------------------------------------------------------------------
/src/pages/settings.js:
--------------------------------------------------------------------------------
1 | import withAuth from '@/middleware/auth'
2 | import { usePageLayout } from '@/components/App/PageLayout'
3 | import { useState, useEffect } from 'react'
4 | import LoadingButton from '@/components/App/LoadingButton'
5 | import Client from '@/utils/Client'
6 | import useAlert from '@/hooks/alert'
7 | import { handleValidationErrors } from '@/utils/errors'
8 | import { logout } from '@/utils/auth'
9 | import useUser from '@/hooks/user'
10 |
11 | const Settings = () => {
12 | const { user, mutate } = useUser()
13 | const { createAlert } = useAlert()
14 | const [errors, setErrors] = useState({})
15 | const [email, setEmail] = useState('')
16 | const [password, setPassword] = useState('')
17 | const [confirmPassword, setConfirmPassword] = useState('')
18 |
19 | useEffect(() => {
20 | setEmail(user?.email)
21 | }, [user])
22 |
23 | const updateEmail = (event, setLoading) => {
24 | event.preventDefault()
25 |
26 | setLoading(true)
27 |
28 | Client.updateEmail({ email })
29 | .then((response) => {
30 | createAlert({ title: 'Email Updated', body: 'Your email has been updated successfully. You may need to verify your account again.' })
31 |
32 | mutate(response)
33 |
34 | setLoading(false)
35 | })
36 | .catch((error) => {
37 | handleValidationErrors(error, setErrors)
38 |
39 | setLoading(false)
40 | })
41 | }
42 |
43 | const openBilling = (event, setLoading) => {
44 | event.preventDefault()
45 |
46 | setLoading(true)
47 |
48 | Client.redirectToBilling()
49 | }
50 |
51 | const updatePassword = (event, setLoading) => {
52 | event.preventDefault()
53 |
54 | setLoading(true)
55 |
56 | Client.updatePassword({ password, confirmPassword })
57 | .then(() => {
58 | createAlert({ title: 'Password Updated', body: 'Your password has been updated successfully. You may need to login again.' })
59 |
60 | setTimeout(() => logout(), 5000)
61 |
62 | setLoading(false)
63 | })
64 | .catch((error) => {
65 | handleValidationErrors(error, setErrors)
66 |
67 | setLoading(false)
68 | })
69 | }
70 |
71 | const connectTwitter = (event, setLoading) => {
72 | event.preventDefault()
73 |
74 | setLoading(true)
75 |
76 | Client.connectTwitter()
77 | }
78 |
79 | const unlinkTwitter = (event) => {
80 | event.preventDefault()
81 |
82 | Client.unlinkTwitter()
83 | .then((response) => {
84 | mutate('/api/user', response)
85 | })
86 | .catch((error) => {
87 | handleValidationErrors(error, setErrors)
88 | })
89 | }
90 |
91 | return (
92 | <>
93 |
94 | setEmail(email)} description="You'll need to verify your email again after updating it." />
95 |
96 |
97 |
98 | setPassword(password)} description="We'll email you to let you know your password has changed." />
99 | setConfirmPassword(password)} />
100 |
101 |
102 |
103 |
104 |
105 |
Connect to Twitter
106 |
107 |
Link your Twitter account to Auralite to automatically cross-post your Auralite posts to your Twitter profile. This is not applied for replies.
108 |
109 |
110 |
111 |
112 |
113 | {user?.has_twitter_token ? 'Unlink account' : 'Link account'}
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 | >
122 | )
123 | }
124 |
125 | const SettingsPanel = ({ title, onSubmit, children, submitDisabled, cta = 'Save', withFooter = true }) => {
126 | const [loading, setLoading] = useState(false)
127 |
128 | return (
129 | onSubmit(event, setLoading)} className="mt-6 bg-white dark:bg-gray-900 shadow sm:rounded-lg overflow-hidden">
130 |
131 |
132 |
{title}
133 |
134 |
137 |
138 | {withFooter && (
139 |
140 |
141 |
142 | {cta}
143 |
144 |
145 |
146 | )}
147 |
148 | )
149 | }
150 |
151 | const SettingsField = ({ label, errors, id, type = 'text', placeholder, value, description, onChange }) => {
152 | return (
153 |
154 |
155 | {label}
156 |
157 |
158 |
onChange(event.target.value)} type={type} required />
159 | {errors[id] && (
160 |
165 | )}
166 |
167 |
{errors[id] ? errors[id][0] : description}
168 |
169 | )
170 | }
171 |
172 | Settings.middleware = withAuth()
173 | Settings.getLayout = usePageLayout('Settings')
174 |
175 | export default Settings
176 |
--------------------------------------------------------------------------------
/src/scss/app.scss:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 |
3 | @import '~react-medium-image-zoom/dist/styles.css';
4 |
5 | @import url('https://fonts.googleapis.com/css2?family=Nunito+Sans:wght@400;600;700&display=swap');
6 |
7 | @font-face {
8 | font-family: 'Verveine Regular';
9 | src: url('/fonts/Verveine/Verveine.eot');
10 | src: url('/fonts/Verveine/Verveine.eot?#iefix') format('embedded-opentype'),
11 | url('/fonts/Verveine/Verveine.woff2') format('woff2'),
12 | url('/fonts/Verveine/Verveine.woff') format('woff'),
13 | url('/fonts/Verveine/Verveine.ttf') format('truetype'),
14 | url('/fonts/Verveine/Verveine.svg#Verveine W01 Regular') format('svg');
15 | }
16 |
17 | strong {
18 | @apply font-bold;
19 | }
20 |
21 | @tailwind components;
22 |
23 | @tailwind utilities;
24 |
25 | .bg-top-pattern {
26 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1951' height='32'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cpath fill='%236875F5' d='M0 0h1951v32H0z'/%3E%3Cg stroke='%23CDDBFE' stroke-width='5'%3E%3Cpath d='M485.68-13.012l19.892-9.015L517.517-2.05m26.531 9.136l19.892-9.015 11.945 19.977m26.531 9.136l19.892-9.015 11.945 19.977'/%3E%3Cpath d='M514.864-2.963l19.892-9.015L546.701 8m26.531 9.135l19.892-9.015 11.945 19.977m26.531 9.135l19.892-9.014 11.945 19.977'/%3E%3C/g%3E%3Cg stroke='%23B4C6FC' stroke-width='5'%3E%3Cpath d='M704.64 6.262l6.523 20.842-21.283 9.42'/%3E%3Cpath d='M718.17-21.482l6.524 20.844-21.284 9.42m-12.3 25.221l6.522 20.842-21.283 9.421'/%3E%3C/g%3E%3Cg stroke='%238DA2FB' stroke-width='5'%3E%3Cpath d='M956.44 7.318l8.315 20.195-20.382 11.24'/%3E%3Cpath d='M967.5-21.498l8.316 20.195-20.382 11.24m-10.056 26.197l8.315 20.195-20.381 11.24'/%3E%3C/g%3E%3Cg stroke='%235850EC' stroke-width='5'%3E%3Cpath d='M826.668-43.847l20.571 7.334-5.285 22.668m12.739 25.001l20.572 7.334-5.285 22.668'/%3E%3Cpath d='M840.68-16.345l20.572 7.334-5.285 22.668m12.739 25.001l20.572 7.334-5.285 22.668'/%3E%3C/g%3E%3Cg stroke='%235850EC' stroke-width='5'%3E%3Cpath d='M1086.618 41.072l10.795-18.985 21.406 9.142m26.833-8.205l10.795-18.985 21.405 9.14'/%3E%3Cpath d='M1116.135 32.049l10.795-18.986 21.406 9.14M1175.169 14l10.795-18.985 21.406 9.14'/%3E%3C/g%3E%3Cg stroke='%23CDDBFE' stroke-width='5'%3E%3Cpath d='M1412.14-16.651l-8.402 20.159-22.36-6.464m-25.633 11.412l-8.4 20.16-22.361-6.464'/%3E%3Cpath d='M1383.942-4.097l-8.4 20.159-22.36-6.464m-25.634 11.413l-8.401 20.16-22.36-6.464'/%3E%3C/g%3E%3Cg stroke='%23B4C6FC' stroke-width='5'%3E%3Cpath d='M1481.513-39.398l21.694 2.518-.05 23.276m18.036 21.494l21.694 2.519-.05 23.276'/%3E%3Cpath d='M1501.353-15.754l21.694 2.518-.05 23.276m18.036 21.495l21.694 2.518-.05 23.276'/%3E%3C/g%3E%3Cg stroke='%238DA2FB' stroke-width='5'%3E%3Cpath d='M1595.366-25.205l20.473-7.605 10.522 20.762m25.829 10.964l20.473-7.605 10.522 20.761'/%3E%3Cpath d='M1623.778-13.144l20.473-7.606L1654.773.012m25.829 10.964l20.473-7.605 10.522 20.762'/%3E%3C/g%3E%3Cg stroke='%235850EC' stroke-width='5'%3E%3Cpath d='M1792.952-41.297l21.033 5.881-3.691 22.981m14.452 24.052l21.033 5.881-3.691 22.981'/%3E%3Cpath d='M1808.849-14.84l21.033 5.881-3.691 22.981m14.452 24.052l21.033 5.881-3.691 22.982'/%3E%3C/g%3E%3Cg stroke='%23B4C6FC' stroke-width='5'%3E%3Cpath d='M1909.884 45.39l10.125-19.351 21.712 8.388m26.531-9.135l10.125-19.35 21.712 8.387'/%3E%3Cpath d='M1939.068 35.34l10.125-19.35 21.712 8.388'/%3E%3C/g%3E%3Cg stroke='%235850EC' stroke-width='5'%3E%3Cpath d='M162.852-30.785l21.483-3.934 6.757 22.273m23.533 15.283l21.482-3.935 6.757 22.273'/%3E%3Cpath d='M188.739-13.974l21.482-3.935 6.757 22.274m23.532 15.282l21.483-3.934 6.757 22.273'/%3E%3C/g%3E%3Cg stroke='%238DA2FB' stroke-width='5'%3E%3Cpath d='M388.942-22.653L395.1-1.7l-21.445 9.048'/%3E%3Cpath d='M402.954-50.155l6.159 20.953-21.445 9.049M374.929 4.848l6.159 20.954-21.446 9.048'/%3E%3C/g%3E%3Cg stroke='%238DA2FB' stroke-width='5'%3E%3Cpath d='M1230.132-15.534l20.16 8.4-6.465 22.36'/%3E%3Cpath d='M1217.578-43.731l20.16 8.4-6.465 22.36m11.413 25.634l20.16 8.4-6.464 22.36'/%3E%3C/g%3E%3Cg stroke='%23CDDBFE' stroke-width='5'%3E%3Cpath d='M92.724-11.095l20.815 6.611-4.49 22.839'/%3E%3Cpath d='M77.76-38.091l20.815 6.611-4.49 22.839M107.688 15.9l20.815 6.612-4.49 22.838'/%3E%3C/g%3E%3Ccircle cx='509.5' cy='29.5' r='10.5' fill='%235850EC' fill-rule='nonzero'/%3E%3Ccircle cx='782' cy='2' r='8' fill='%23CDDBFE' fill-rule='nonzero'/%3E%3Ccircle cx='153.5' cy='8.5' r='7.5' fill='%23B4C6FC' fill-rule='nonzero'/%3E%3Ccircle cx='1452' cy='2' r='10' fill='%235850EC' fill-rule='nonzero'/%3E%3Ccircle cx='1605' cy='20' r='10' fill='%23CDDBFE' fill-rule='nonzero'/%3E%3Cpath fill='%235850EC' fill-rule='nonzero' d='M1030 9.962L1048.36 9l-17.398 19.322z'/%3E%3Cpath fill='%23CDDBFE' fill-rule='nonzero' d='M322.276 9.276L305 2.988 328.564-8z'/%3E%3Cpath fill='%236279E0' fill-rule='nonzero' d='M1801.582 3.356l-14.877-4.548 19.425-10.328z'/%3E%3Cpath fill='%235850EC' fill-rule='nonzero' d='M1818.38 45.685l-3.5-15.158 18.658 11.658z'/%3E%3Cpath fill='%23CDDBFE' fill-rule='nonzero' d='M1887.877 21.876L1873 17.328 1892.425 7z'/%3E%3Ccircle cx='74.5' cy='14.5' r='7.5' fill='%235850EC' fill-rule='nonzero'/%3E%3Cpath fill='%23CDDBFE' fill-rule='nonzero' d='M457.276 40.276L440 33.988 463.564 23z'/%3E%3Cg stroke='%238DA2FB' stroke-width='5'%3E%3Cpath d='M1749.355 5.475l21.548-3.559 6.367 22.388'/%3E%3Cpath d='M1774.944 22.735l21.548-3.56 6.367 22.389'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
27 | }
28 |
29 | .overflow-ellipsis {
30 | text-overflow: ellipsis;
31 | }
32 |
33 | .bg-crystal {
34 | backdrop-filter: blur(5px);
35 | }
36 |
37 | @responsive {
38 | @variants dark {
39 | .dark-bg::before {
40 | content: '';
41 | background: url('/img/bg-dark.png');
42 | @apply w-full h-full inline-block left-0 top-0 fixed block bg-cover bg-no-repeat bg-center -z-1 pointer-events-none;
43 | }
44 | }
45 | }
--------------------------------------------------------------------------------
/src/utils/Client.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 | import Cookies from 'js-cookie'
3 | import redirectTo from './redirectTo'
4 | import { logout } from './auth'
5 |
6 | class Client {
7 | constructor() {
8 | this.clientId = process.env.AURALITE_ID
9 | this.clientSecret = process.env.AURALITE_SECRET
10 | this.apiToken = Cookies.get('auralite_token')
11 | this.baseURL = process.env.NEXT_PUBLIC_AURALITE_URL
12 |
13 | this.client = axios.create({
14 | baseURL: this.baseURL,
15 | headers: {
16 | Accept: 'application/json',
17 | ...(this.apiToken ? { Authorization: `Bearer ${this.apiToken}` } : {}),
18 | },
19 | })
20 |
21 | this.client.interceptors.response.use(
22 | (response) => response.data,
23 | (error) => {
24 | if (error.response.status === 417 && error.response.data._force_redirect_to) return redirectTo(error.response.data._force_redirect_to)
25 | if (error.response.status === 401) return logout()
26 |
27 | return Promise.reject(error)
28 | }
29 | )
30 | }
31 |
32 | login({ email, password }) {
33 | return this.client.post('/oauth/token', {
34 | grant_type: 'password',
35 | client_id: this.clientId,
36 | client_secret: this.clientSecret,
37 | username: email,
38 | password,
39 | scope: '*',
40 | })
41 | }
42 |
43 | register({ email, password, code }) {
44 | return this.client.post('/oauth/register', {
45 | client_id: this.clientId,
46 | client_secret: this.clientSecret,
47 | email,
48 | password,
49 | code,
50 | })
51 | }
52 |
53 | user() {
54 | return this.client.get('/api/user')
55 | }
56 |
57 | profile({ handle }) {
58 | return this.client.get(`/api/profiles/${handle}`)
59 | }
60 |
61 | updateProfile({ bio, avatar }) {
62 | return this.client.post('/api/profile/update', { bio, avatar })
63 | }
64 |
65 | timeline({ page = 1, includeRead = true }) {
66 | return this.client.get(`/api/timeline${includeRead ? '' : '/unread'}?page=${page}`)
67 | }
68 |
69 | notifications() {
70 | return this.client.get('/api/notifications')
71 | }
72 |
73 | search({ query, page = 1 }) {
74 | return this.client.get(`/api/search?q=${query}&page=${page}`)
75 | }
76 |
77 | post({ postId }) {
78 | return this.client.get(`/api/posts/${postId}`)
79 | }
80 |
81 | createPost({ post, reply_to, privacy, images }) {
82 | return this.client.post('/api/posts', { content: post, reply_to, privacy, images })
83 | }
84 |
85 | deletePost({ postId }) {
86 | return this.client.delete(`/api/posts/${postId}`)
87 | }
88 |
89 | markNotificationRead({ id }) {
90 | return this.client.post(`/api/notifications/${id}/read`)
91 | }
92 |
93 | resendEmailVerification() {
94 | return this.client.post('/api/onboarding/email-verification/resend')
95 | }
96 |
97 | createProfile({ name, handle, bio, avatar }) {
98 | return this.client.put('/api/onboarding/profile', { name, handle, bio, avatar })
99 | }
100 |
101 | startIdentityVerification() {
102 | return this.client.post('/api/onboarding/identity/start')
103 | }
104 |
105 | launchCheckout({ plan }) {
106 | return this.client.post('/api/onboarding/subscription/checkout', { plan })
107 | }
108 |
109 | updateEmail({ email }) {
110 | return this.client.post('/api/user/update', { email })
111 | }
112 |
113 | updatePassword({ password, confirmPassword }) {
114 | return this.client.post('/api/user/security', { password, password_confirmation: confirmPassword })
115 | }
116 |
117 | updateAuthToken(token) {
118 | this.apiToken = token
119 | this.client.defaults.headers.Authorization = `Bearer ${token}`
120 | }
121 |
122 | redirectToBilling() {
123 | return this.client.post('/api/user/billing')
124 | }
125 |
126 | connectTwitter() {
127 | redirectTo(`${this.baseURL}/connections/twitter?token=${encodeURIComponent(this.apiToken)}`)
128 | }
129 |
130 | unlinkTwitter() {
131 | return this.client.delete('/api/connections/twitter')
132 | }
133 |
134 | markPostRead({ postId }) {
135 | return this.client.post(`/api/posts/${postId}/view`)
136 | }
137 |
138 | async uploadFile({ file, progress = () => {} }) {
139 | const response = await this.client.post('/api/asset-upload', { content_type: file.type })
140 |
141 | let headers = response.headers
142 |
143 | if ('Host' in headers) delete headers.Host
144 |
145 | return axios
146 | .put(response.url, file, {
147 | headers,
148 | onUploadProgress: (progressEvent) => progress(progressEvent.loaded / progressEvent.total),
149 | })
150 | .then(() => ({ ...response, extension: file.name.split('.').pop() }))
151 | }
152 | }
153 |
154 | export default new Client()
155 |
--------------------------------------------------------------------------------
/src/utils/arr.js:
--------------------------------------------------------------------------------
1 | export const random = (arr) => arr[~~(Math.random() * arr.length)]
2 |
--------------------------------------------------------------------------------
/src/utils/auth.js:
--------------------------------------------------------------------------------
1 | import Cookies from 'js-cookie'
2 | import Client from './Client'
3 | const cookieSettings = { expires: 2628000, sameSite: 'lax' }
4 |
5 | export const login = (token) => {
6 | Cookies.set('auralite_token', token, cookieSettings)
7 |
8 | Client.updateAuthToken(token)
9 | }
10 |
11 | export const logout = () => {
12 | Cookies.remove('auralite_token', cookieSettings)
13 |
14 | window.location = '/login'
15 | }
16 |
--------------------------------------------------------------------------------
/src/utils/constants.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable prettier/prettier */
2 |
3 | export const ZERO_TAGLINES = [
4 | 'Enjoy your day!',
5 | 'Take a moment for yourself',
6 | 'Time to enjoy a nice cup of coffee',
7 | 'Enjoy your favourite playlist',
8 | "Let's go outside",
9 | 'Play time!',
10 | ]
11 |
--------------------------------------------------------------------------------
/src/utils/encoding.js:
--------------------------------------------------------------------------------
1 | export const base64 = (str) => {
2 | if (typeof window !== 'undefined') return btoa(str)
3 |
4 | return Buffer.from(str).toString('base64')
5 | }
6 |
--------------------------------------------------------------------------------
/src/utils/errors.js:
--------------------------------------------------------------------------------
1 | export const handleValidationErrors = (error, setErrors, message = 'Something went wrong. Please try again later.') => {
2 | if (error.response.status !== 422) return alert(message)
3 |
4 | setErrors(error.response.data.errors)
5 | }
6 |
--------------------------------------------------------------------------------
/src/utils/puppeteer.js:
--------------------------------------------------------------------------------
1 | import chrome from 'chrome-aws-lambda'
2 | import puppeteer from 'puppeteer-core'
3 |
4 | let _page
5 | const exePath = process.platform === 'win32' ? 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe' : process.platform === 'linux' ? '/usr/bin/google-chrome' : '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
6 | const isLocal = process.env.VERCEL_REGION === 'dev1'
7 |
8 | export const getScreenshot = async (url) => {
9 | const page = await getPage()
10 |
11 | await page.setViewport({ width: 1572, height: 960 })
12 |
13 | await page.goto(url)
14 |
15 | return await page.screenshot({ type: 'jpeg' })
16 | }
17 |
18 | const getPage = async () => {
19 | if (_page) return _page
20 |
21 | const browser = await puppeteer.launch(
22 | isLocal
23 | ? {
24 | args: [],
25 | executablePath: exePath,
26 | headless: true,
27 | }
28 | : {
29 | args: chrome.args,
30 | executablePath: await chrome.executablePath,
31 | headless: chrome.headless,
32 | }
33 | )
34 |
35 | _page = await browser.newPage()
36 |
37 | return _page
38 | }
39 |
--------------------------------------------------------------------------------
/src/utils/redirectTo.js:
--------------------------------------------------------------------------------
1 | import Router from 'next/router'
2 |
3 | export default function redirectTo(destination, { res, status } = {}) {
4 | if (res) {
5 | res.writeHead(status || 302, { Location: destination })
6 | res.end()
7 | } else {
8 | if (destination[0] === '/' && destination[1] !== '/') {
9 | Router.push(destination)
10 | } else {
11 | window.location = destination
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const defaultTheme = require('tailwindcss/defaultTheme')
2 | const plugin = require('tailwindcss/plugin')
3 | const selectorParser = require('postcss-selector-parser')
4 |
5 | module.exports = {
6 | purge: ['./src/**/*.{js,mdx}', './next.config.js'],
7 | theme: {
8 | extend: {
9 | height: {
10 | header: 'calc(max(1rem, env(safe-area-inset-top)) + 3rem)',
11 | },
12 | borderRadius: {
13 | xl: '1rem',
14 | '2xl': '2rem',
15 | },
16 | lineHeight: {
17 | px: '1',
18 | },
19 | letterSpacing: {
20 | micro: '-0.01em',
21 | },
22 | fontSize: {
23 | '6xl': '4.5rem',
24 | },
25 | backgroundOpacity: {
26 | 10: '.1',
27 | 90: '.9',
28 | },
29 | boxShadow: {
30 | header: 'rgb(0, 0, 0, .05) 0px 3px 6px 0px',
31 | 'header-dark': 'rgb(255, 255, 255, .02) 0px 3px 6px 0px',
32 | footer: '0 -3px 6px 0 rgba(0, 0, 0, 0.05)',
33 | 'footer-dark': '0 -3px 6px 0 rgb(255, 255, 255, .02)',
34 | },
35 | fontFamily: {
36 | screen: ["'Nunito Sans'", ...defaultTheme.fontFamily.sans],
37 | 'screen-italic': ["'Verveine Regular'", ...defaultTheme.fontFamily.sans],
38 | },
39 | inset: {
40 | card: 'calc(max(1rem, env(safe-area-inset-top)) + 4rem)',
41 | },
42 | spacing: {
43 | 'safe-t': 'max(1rem, env(safe-area-inset-top))',
44 | },
45 | padding: {
46 | 'safe-b': 'env(safe-area-inset-bottom)',
47 | header: 'calc(max(1rem, env(safe-area-inset-top)) + 3rem)',
48 | footer: 'calc(env(safe-area-inset-top) + 3.5rem)',
49 | },
50 | rotate: {
51 | '-2': '-2deg',
52 | },
53 | zIndex: {
54 | '-1': -1,
55 | 1: 1,
56 | },
57 | fill: {
58 | none: 'none',
59 | },
60 | animation: {
61 | ring: 'ring 1s ease-in-out 0s 1',
62 | },
63 | keyframes: {
64 | ring: {
65 | '0%, 80%': {
66 | transform: 'rotate(0deg)',
67 | },
68 | '16%, 48%': {
69 | transform: 'rotate(15deg)',
70 | },
71 | '32%, 64%': {
72 | transform: 'rotate(-15deg)',
73 | },
74 | },
75 | },
76 | typography: (theme) => ({
77 | default: {
78 | css: {
79 | a: {
80 | color: theme('colors.indigo.500'),
81 | fontWeight: 700,
82 | },
83 | strong: {
84 | color: theme('colors.indigo.500'),
85 | fontWeight: 700,
86 | },
87 | },
88 | },
89 | dark: {
90 | css: {
91 | color: theme('colors.gray.300'),
92 | blockquote: {
93 | color: theme('colors.gray.300'),
94 | borderLeftColor: theme('colors.gray.700'),
95 | },
96 | hr: {
97 | borderTopColor: theme('colors.gray.800'),
98 | },
99 | strong: {
100 | color: theme('colors.white'),
101 | },
102 | 'figure figcaption': {
103 | color: theme('colors.gray.500'),
104 | },
105 | a: {
106 | color: theme('colors.white'),
107 | },
108 | th: {
109 | color: theme('colors.white'),
110 | },
111 | 'h1, h2, h3, h4': {
112 | color: theme('colors.white'),
113 | },
114 | code: {
115 | color: theme('colors.gray.300'),
116 | },
117 | 'code:before': {
118 | color: theme('colors.gray.300'),
119 | },
120 | 'code:after': {
121 | color: theme('colors.gray.300'),
122 | },
123 | 'ol > li:before': {
124 | color: theme('colors.gray.400'),
125 | },
126 | 'ul > li:before': {
127 | backgroundColor: theme('colors.gray.600'),
128 | },
129 | },
130 | },
131 | }),
132 | },
133 | },
134 | variants: {
135 | backgroundColor: ['responsive', 'hover', 'focus', 'dark', 'dark-hover'],
136 | textColor: ['responsive', 'hover', 'focus', 'dark', 'dark-hover'],
137 | borderColor: ['responsive', 'hover', 'focus', 'dark', 'dark-hover'],
138 | backgroundOpacity: ['responsive', 'hover', 'focus', 'dark', 'dark-hover'],
139 | boxShadow: ['responsive', 'hover', 'focus', 'dark'],
140 | display: ['responsive', 'group-hover'],
141 | opacity: ['responsive', 'hover', 'focus', 'group-hover'],
142 | typography: ['responsive', 'dark'],
143 | },
144 | plugins: [
145 | plugin(function ({ addVariant, prefix, e }) {
146 | addVariant('dark', ({ modifySelectors, separator }) => {
147 | modifySelectors(({ selector }) => {
148 | return selectorParser((selectors) => {
149 | selectors.walkClasses((sel) => {
150 | sel.value = `dark${separator}${sel.value}`
151 | sel.parent.insertBefore(sel, selectorParser().astSync(prefix('.scheme-dark ')))
152 | })
153 | }).processSync(selector)
154 | })
155 | })
156 | addVariant('dark-hover', ({ modifySelectors, separator }) => {
157 | modifySelectors(({ className }) => {
158 | return `.scheme-dark .${e(`dark-hover${separator}${className}`)}:hover`
159 | })
160 | })
161 | }),
162 | require('@tailwindcss/ui'),
163 | require('@tailwindcss/typography'),
164 | ],
165 | }
166 |
--------------------------------------------------------------------------------