Merge branch 'main' into feature/update-read-me
This commit is contained in:
commit
60b893c872
@ -22,5 +22,8 @@ KLAVIYO_LIST_ID=
|
||||
|
||||
REVUE_API_KEY=
|
||||
|
||||
# Create EmailOctopus API key at https://emailoctopus.com/api-documentation
|
||||
EMAILOCTOPUS_API_KEY=
|
||||
# List ID can be found in the URL as a UUID after clicking a list on https://emailoctopus.com/lists
|
||||
# or the settings page of your list https://emailoctopus.com/lists/{UUID}/settings
|
||||
EMAILOCTOPUS_LIST_ID=
|
||||
|
@ -1,4 +1 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npx --no-install lint-staged
|
||||
|
@ -42,7 +42,7 @@ Internationalization support - [Template with i18n](https://tailwind-nextjs-star
|
||||
- [enscribe.dev](https://enscribe.dev) - enscribe's personal blog; cybersecurity shenanigans, frontend webdev, etc. ([source code](https://github.com/jktrn/enscribe.dev))
|
||||
- [dalelarroder.com](https://dalelarroder.com) - Dale Larroder's personal website upgraded from V1 ([source code](https://github.com/dlarroder/dalelarroder))
|
||||
- [thetalhatahir.com](https://www.thetalhatahir.com) - Talha Tahir's personal blog. Added article thumbnails, linkedIn card, Beautiful hero content, technology emoticons.
|
||||
- [hauhau.cn](https://www.hauhau.cn) - Homing's personal blog about the stuff he's learning ([source code](https://github.com/hominsu/blog))
|
||||
- [homing.so](https://homing.so) - Homing's personal blog about the stuff he's learning ([source code](https://github.com/hominsu/blog))
|
||||
- [zS1m's Blog](https://contrails.space) - zS1m's personal blog for recording and sharing daily learning technical content ([source code](https://github.com/zS1m/nextjs-contrails))
|
||||
- [dariuszwozniak.net](https://dariuszwozniak.net/) - Software development blog
|
||||
- [Terminals.run](https://terminals.run) - Blog site for some thoughts and records for life and technology.
|
||||
@ -57,6 +57,8 @@ Internationalization support - [Template with i18n](https://tailwind-nextjs-star
|
||||
- [Hans Blog](https://www.hansking.cn/) - Hans' personal blog, front-end technology, gallery and travel diary 中文. ([source code](https://github.com/hansking98/hans-nextjs-blog))
|
||||
- [CuB3y0nd's Portfolio](https://www.cubeyond.net/) - CuB3y0nd‘s cyber security study notes「中文」
|
||||
- [London Tech Talk](https://london-tech-talk.com/) - A podcast exploring technology trends and expatriate living experiences. - 日本語
|
||||
- [CRUD Flow Blog](http://blog.ndamulelo.co.za/) - A technical blog about AI, Cloud Engineering, Data Science and Personal development
|
||||
- [Trillium's Blog](https://trilliumsmith.com/) - Modified to render resume pdf on `/resume` page
|
||||
- [Frank's Tech Blog](https://frank-tech-blog.vercel.app/) - Frank's personal blog about software development and technology. ([source code](https://github.com/frank-mendez/frank-blog))
|
||||
|
||||
Using the template? Feel free to create a PR and add your blog to this list.
|
||||
@ -98,7 +100,8 @@ Thanks to the community of users and contributors to the template! We are no lon
|
||||
- [https://bitoflearning-9a57.fly.dev/](https://bitoflearning-9a57.fly.dev/) - Sangeet Agarwal's personal blog, replatformed to [remix](https://remix.run/remix) using the [indie stack](https://github.com/remix-run/indie-stack) ([source code](https://github.com/SangeetAgarwal/bitoflearning))
|
||||
- [raphaelchelly.com](https://www.raphaelchelly.com/) - Raphaël Chelly's personal website and blog ([source code](https://github.com/raphaelchelly/raph_www))
|
||||
- [kaveh.page](https://kaveh.page) - Kaveh Tehrani's personal blog. Added tags directory, profile card, time-to-read on posts directory, etc.
|
||||
|
||||
- [drakerossman.com](https://drakerossman.com/) - Drake Rossman's blog about NixOS, Rust, Software Architecture and Engineering Management, as well as general musings.
|
||||
|
||||
## Motivation
|
||||
|
||||
I wanted to port my existing blog to Nextjs and Tailwind CSS but there was no easy out of the box template to use so I decided to create one. Design is adapted from [Tailwindlabs blog](https://github.com/tailwindlabs/blog.tailwindcss.com).
|
||||
|
@ -73,7 +73,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#fff" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000" />
|
||||
<link rel="alternate" type="application/rss+xml" href="/feed.xml" />
|
||||
<body className="bg-white text-black antialiased dark:bg-gray-950 dark:text-white">
|
||||
<body className="bg-white pl-[calc(100vw-100%)] text-black antialiased dark:bg-gray-950 dark:text-white">
|
||||
<ThemeProviders>
|
||||
<Analytics analyticsConfig={siteMetadata.analytics as AnalyticsConfig} />
|
||||
<SectionContainer>
|
||||
|
@ -1 +1,20 @@
|
||||
{"next-js":6,"tailwind":3,"guide":5,"feature":2,"multi-author":1,"hello":1,"math":1,"ols":1,"github":1,"writings":1,"book":1,"reflection":1,"holiday":1,"canada":1,"images":1,"markdown":1,"code":1,"features":1}
|
||||
{
|
||||
"markdown": 1,
|
||||
"code": 1,
|
||||
"features": 1,
|
||||
"next-js": 6,
|
||||
"math": 1,
|
||||
"ols": 1,
|
||||
"github": 1,
|
||||
"guide": 5,
|
||||
"tailwind": 3,
|
||||
"hello": 1,
|
||||
"holiday": 1,
|
||||
"canada": 1,
|
||||
"images": 1,
|
||||
"feature": 2,
|
||||
"writings": 1,
|
||||
"book": 1,
|
||||
"reflection": 1,
|
||||
"multi-author": 1
|
||||
}
|
||||
|
@ -6,11 +6,16 @@ import siteMetadata from '@/data/siteMetadata'
|
||||
|
||||
export default function Comments({ slug }: { slug: string }) {
|
||||
const [loadComments, setLoadComments] = useState(false)
|
||||
|
||||
if (!siteMetadata.comments?.provider) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{!loadComments && <button onClick={() => setLoadComments(true)}>Load Comments</button>}
|
||||
{siteMetadata.comments && loadComments && (
|
||||
{loadComments ? (
|
||||
<CommentsComponent commentsConfig={siteMetadata.comments} slug={slug} />
|
||||
) : (
|
||||
<button onClick={() => setLoadComments(true)}>Load Comments</button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
@ -1,7 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Fragment, useEffect, useState } from 'react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { Menu, RadioGroup, Transition } from '@headlessui/react'
|
||||
|
||||
const Sun = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="h-6 w-6 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
const Moon = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="h-6 w-6 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
|
||||
</svg>
|
||||
)
|
||||
const Monitor = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
className="h-6 w-6 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<rect x="3" y="3" width="14" height="10" rx="2" ry="2"></rect>
|
||||
<line x1="7" y1="17" x2="13" y2="17"></line>
|
||||
<line x1="10" y1="13" x2="10" y2="17"></line>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ThemeSwitch = () => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
@ -10,32 +52,60 @@ const ThemeSwitch = () => {
|
||||
// When mounted on client, now we can show the UI
|
||||
useEffect(() => setMounted(true), [])
|
||||
|
||||
if (!mounted) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Toggle Dark Mode"
|
||||
onClick={() => setTheme(theme === 'dark' || resolvedTheme === 'dark' ? 'light' : 'dark')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="h-6 w-6 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{mounted && (theme === 'dark' || resolvedTheme === 'dark') ? (
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
) : (
|
||||
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
<div className="mr-5">
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<div>
|
||||
<Menu.Button>{resolvedTheme === 'dark' ? <Moon /> : <Sun />}</Menu.Button>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="absolute right-0 mt-2 w-32 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-gray-800">
|
||||
<RadioGroup value={theme} onChange={setTheme}>
|
||||
<div className="p-1">
|
||||
<RadioGroup.Option value="light">
|
||||
<Menu.Item>
|
||||
<button className="group flex w-full items-center rounded-md px-2 py-2 text-sm">
|
||||
<div className="mr-2">
|
||||
<Sun />
|
||||
</div>
|
||||
Light
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</RadioGroup.Option>
|
||||
<RadioGroup.Option value="dark">
|
||||
<Menu.Item>
|
||||
<button className="group flex w-full items-center rounded-md px-2 py-2 text-sm">
|
||||
<div className="mr-2">
|
||||
<Moon />
|
||||
</div>
|
||||
Dark
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</RadioGroup.Option>
|
||||
<RadioGroup.Option value="system">
|
||||
<Menu.Item>
|
||||
<button className="group flex w-full items-center rounded-md px-2 py-2 text-sm">
|
||||
<div className="mr-2">
|
||||
<Monitor />
|
||||
</div>
|
||||
System
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</RadioGroup.Option>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { defineDocumentType, ComputedFields, makeSource } from 'contentlayer/source-files'
|
||||
import { defineDocumentType, ComputedFields, makeSource } from 'contentlayer2/source-files'
|
||||
import { writeFileSync } from 'fs'
|
||||
import readingTime from 'reading-time'
|
||||
import { slug } from 'github-slugger'
|
||||
import path from 'path'
|
||||
import { fromHtmlIsomorphic } from 'hast-util-from-html-isomorphic'
|
||||
// Remark packages
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
@ -25,6 +26,19 @@ import { allCoreContent, sortPosts } from 'pliny/utils/contentlayer.js'
|
||||
const root = process.cwd()
|
||||
const isProduction = process.env.NODE_ENV === 'production'
|
||||
|
||||
// heroicon mini link
|
||||
const icon = fromHtmlIsomorphic(
|
||||
`
|
||||
<span class="content-header-link">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-5 h-5 linkicon">
|
||||
<path d="M12.232 4.232a2.5 2.5 0 0 1 3.536 3.536l-1.225 1.224a.75.75 0 0 0 1.061 1.06l1.224-1.224a4 4 0 0 0-5.656-5.656l-3 3a4 4 0 0 0 .225 5.865.75.75 0 0 0 .977-1.138 2.5 2.5 0 0 1-.142-3.667l3-3Z" />
|
||||
<path d="M11.603 7.963a.75.75 0 0 0-.977 1.138 2.5 2.5 0 0 1 .142 3.667l-3 3a2.5 2.5 0 0 1-3.536-3.536l1.225-1.224a.75.75 0 0 0-1.061-1.06l-1.224 1.224a4 4 0 1 0 5.656 5.656l3-3a4 4 0 0 0-.225-5.865Z" />
|
||||
</svg>
|
||||
</span>
|
||||
`,
|
||||
{ fragment: true }
|
||||
)
|
||||
|
||||
const computedFields: ComputedFields = {
|
||||
readingTime: { type: 'json', resolve: (doc) => readingTime(doc.body.raw) },
|
||||
slug: {
|
||||
@ -142,7 +156,16 @@ export default makeSource({
|
||||
],
|
||||
rehypePlugins: [
|
||||
rehypeSlug,
|
||||
rehypeAutolinkHeadings,
|
||||
[
|
||||
rehypeAutolinkHeadings,
|
||||
{
|
||||
behavior: 'prepend',
|
||||
headingProperties: {
|
||||
className: ['content-header'],
|
||||
},
|
||||
content: icon,
|
||||
},
|
||||
],
|
||||
rehypeKatex,
|
||||
[rehypeCitation, { path: path.join(root, 'data') }],
|
||||
[rehypePrismPlus, { defaultLanguage: 'js', ignoreMissing: true }],
|
||||
|
@ -33,3 +33,19 @@ input:-webkit-autofill:focus {
|
||||
.katex-display {
|
||||
overflow: auto hidden;
|
||||
}
|
||||
|
||||
.content-header-link {
|
||||
opacity: 0;
|
||||
margin-left: -24px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.content-header:hover .content-header-link,
|
||||
.content-header-link:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.linkicon {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
@ -33,7 +33,7 @@ export default Home
|
||||
|
||||
For a markdown file, the default image tag can be used and the default `img` tag gets replaced by the `Image` component in the build process.
|
||||
|
||||
Assuming we have a file called `ocean.jpg` in `data/img/ocean.jpg`, the following line of code would generate the optimized image.
|
||||
Assuming we have a file called `ocean.jpg` in `static/images/ocean.jpg`, the following line of code would generate the optimized image.
|
||||
|
||||
```
|
||||
![ocean](/static/images/ocean.jpg)
|
||||
|
@ -81,7 +81,9 @@ export default function PostLayout({ content, authorDetails, next, prev, childre
|
||||
href={author.twitter}
|
||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
>
|
||||
{author.twitter.replace('https://twitter.com/', '@')}
|
||||
{author.twitter
|
||||
.replace('https://twitter.com/', '@')
|
||||
.replace('https://x.com/', '@')}
|
||||
</Link>
|
||||
)}
|
||||
</dd>
|
||||
|
@ -1,4 +1,4 @@
|
||||
const { withContentlayer } = require('next-contentlayer')
|
||||
const { withContentlayer } = require('next-contentlayer2')
|
||||
|
||||
const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
||||
enabled: process.env.ANALYZE === 'true',
|
||||
|
62
package.json
62
package.json
@ -8,64 +8,58 @@
|
||||
"build": "cross-env INIT_CWD=$PWD next build && cross-env NODE_OPTIONS='--experimental-json-modules' node ./scripts/postbuild.mjs",
|
||||
"serve": "next start",
|
||||
"analyze": "cross-env ANALYZE=true next build",
|
||||
"lint": "next lint --fix --dir pages --dir app --dir components --dir lib --dir layouts --dir scripts"
|
||||
"lint": "next lint --fix --dir pages --dir app --dir components --dir lib --dir layouts --dir scripts",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@next/bundle-analyzer": "14.1.0",
|
||||
"@tailwindcss/forms": "^0.5.4",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@headlessui/react": "1.7.19",
|
||||
"@next/bundle-analyzer": "14.2.1",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@tailwindcss/typography": "^0.5.12",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"contentlayer": "0.3.4",
|
||||
"esbuild": "0.18.11",
|
||||
"contentlayer2": "0.4.4",
|
||||
"esbuild": "0.20.2",
|
||||
"github-slugger": "^2.0.0",
|
||||
"gray-matter": "^4.0.2",
|
||||
"hast-util-from-html-isomorphic": "^2.0.0",
|
||||
"image-size": "1.0.0",
|
||||
"next": "14.1.0",
|
||||
"next-contentlayer": "0.3.4",
|
||||
"next-themes": "^0.2.1",
|
||||
"pliny": "0.1.7",
|
||||
"next": "14.2.1",
|
||||
"next-contentlayer2": "0.4.4",
|
||||
"next-themes": "^0.3.0",
|
||||
"pliny": "0.2.0",
|
||||
"postcss": "^8.4.24",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"reading-time": "1.5.0",
|
||||
"rehype-autolink-headings": "^6.1.0",
|
||||
"rehype-citation": "^1.0.2",
|
||||
"rehype-katex": "^6.0.3",
|
||||
"rehype-preset-minify": "6.0.0",
|
||||
"rehype-prism-plus": "^1.6.0",
|
||||
"rehype-slug": "^5.1.0",
|
||||
"remark": "^14.0.2",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"remark-math": "^5.1.1",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"unist-util-visit": "^4.1.0"
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-citation": "^2.0.0",
|
||||
"rehype-katex": "^7.0.0",
|
||||
"rehype-preset-minify": "7.0.0",
|
||||
"rehype-prism-plus": "^2.0.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark": "^15.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"unist-util-visit": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/webpack": "^8.0.1",
|
||||
"@types/mdx": "^2.0.5",
|
||||
"@types/react": "^18.2.14",
|
||||
"@types/mdx": "^2.0.12",
|
||||
"@types/react": "^18.2.73",
|
||||
"@typescript-eslint/eslint-plugin": "^6.1.0",
|
||||
"@typescript-eslint/parser": "^6.1.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-config-next": "14.1.0",
|
||||
"eslint-config-next": "14.2.1",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"husky": "^8.0.0",
|
||||
"husky": "^9.0.0",
|
||||
"lint-staged": "^13.0.0",
|
||||
"prettier": "^3.0.0",
|
||||
"prettier-plugin-tailwindcss": "^0.5.11",
|
||||
"typescript": "^5.1.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"@opentelemetry/api": "1.4.1",
|
||||
"@opentelemetry/core": "1.13.0",
|
||||
"@opentelemetry/exporter-trace-otlp-grpc": "0.39.1",
|
||||
"@opentelemetry/resources": "1.13.0",
|
||||
"@opentelemetry/sdk-trace-base": "1.13.0",
|
||||
"@opentelemetry/sdk-trace-node": "1.13.0",
|
||||
"@opentelemetry/semantic-conventions": "1.13.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.+(js|jsx|ts|tsx)": [
|
||||
"eslint --fix"
|
||||
|
Loading…
x
Reference in New Issue
Block a user