-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
✨ initial creation of dev.to profile and random-fox components (#2)
- Loading branch information
Showing
11 changed files
with
1,271 additions
and
64 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
# Dev.to profile card widget | ||
|
||
WIP for use on scottnath.com only | ||
|
||
## @todo | ||
|
||
- [ ] Fetch non-key data from api | ||
- [ ] i18 || configure titles | ||
- [ ] use `data-thing` for attributes? | ||
- [ ] re-do `parts`, removing parts that are just for style-sharing | ||
- [ ] interaction tests | ||
- [ ] a11y testing | ||
- [ ] create custom element manifest | ||
- [ ] need [plugins](https://custom-elements-manifest.open-wc.org/blog/intro/#plugins) to do JSDoc correctly | ||
- [ ] separate out fetch and data handling from component | ||
- [ ] alt-option that allows use of api-key | ||
- [ ] test in plain HTML page | ||
- [ ] test in Qwik | ||
- [ ] test in Next.js | ||
|
||
## Inspriation | ||
|
||
- https://dev.to/asheeshh/devembed-embed-your-devto-profile-anywhere-using-widgets-linode-hacakathon-4659 | ||
- https://dev.to/saurabhdaware/i-made-dev-to-widget-for-websites-blogs-40p2 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
|
||
export const smileySvg = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAAAJUlEQVR42u3NQQEAAAQEsJNcdFLw2gqsMukcK4lEIpFIJBLJS7KG6yVo40DbTgAAAABJRU5ErkJggg==' | ||
|
||
export const snUserFixture = { | ||
id: 1055555, | ||
username: 'scottnath', | ||
name: 'Scott Nath', | ||
summary: "I'm sorry, as an a.i. language model I cannot write your bio for you.", | ||
joined_at: 'Mar 30, 2023', | ||
profile_image: 'https://res.cloudinary.com/practicaldev/image/fetch/s--8gi1l6OI--/c_fill,f_auto,fl_progressive,h_320,q_auto,w_320/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/1055555/8146c5bb-31d3-4023-a216-5cb5c00ecb3b.jpg', | ||
joined: '2023-03-30', | ||
post_count: 8 | ||
} | ||
|
||
export const snPostFixture = { | ||
title: 'Sharing UI Tests Between Javascript Frameworks', | ||
description: 'How to share testing-library UI tests between Javascript frameworks with the same or similar components and use them in Storybook and unit testing.', | ||
url: 'https://dev.to/scottnath/sharing-ui-tests-between-javascript-frameworks-2l6n', | ||
cover_image: 'https://res.cloudinary.com/practicaldev/image/fetch/s--NqWkGO2---/c_imagga_scale,f_auto,fl_progressive,h_420,q_auto,w_1000/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/z5070dozgnb32uwtm5dm.png', | ||
} | ||
|
||
export const meowUserFixture = { | ||
id: 1055555, | ||
username: 'meowmeow', | ||
name: 'Meow Meow', | ||
summary: "Just a meow, with a meow's worries. Nothin but a meow.", | ||
joined_at: 'Feb 29, 2020', | ||
profile_image: smileySvg, | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
|
||
import './'; | ||
import { snUserFixture, meowUserFixture } from './devto.shared-spec'; | ||
|
||
export default { | ||
title: 'DevTo', | ||
component: 'dev-user', | ||
tags: ['autodocs'], | ||
}; | ||
|
||
export const Username = { | ||
args: { | ||
username: 'scottnath', | ||
}, | ||
}; | ||
|
||
export const NewUser = { | ||
args: { | ||
username: 'soyecoder', | ||
}, | ||
} | ||
|
||
export const AltUserData = { | ||
args: { | ||
user: { | ||
...snUserFixture, | ||
name: 'Someother Name', | ||
summary: 'Different summary content than what is from the dev.to api', | ||
joined_at: 'Jan 1, 1979', | ||
post_count: 1, | ||
}, | ||
}, | ||
} | ||
|
||
export const SkipFetch = { | ||
args: { | ||
user: meowUserFixture, | ||
skipFetch: true, | ||
}, | ||
} | ||
|
||
export const SkipFetchJustUsername = { | ||
args: { | ||
user: { | ||
username: 'scottnath' | ||
}, | ||
skipFetch: true, | ||
}, | ||
} | ||
|
||
export const SkipFetchFail = { | ||
args: { | ||
skipFetch: true, | ||
}, | ||
} | ||
|
||
export const UnknownUsername = { | ||
args: { | ||
username: 'NotAUserMeow', | ||
}, | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,272 @@ | ||
import { LitElement, html, css, unsafeCSS } from 'lit'; | ||
import {when} from 'lit/directives/when.js'; | ||
|
||
// import stylesDep from './style.css' | ||
import styles from './styles.css?inline' | ||
|
||
/** | ||
* Blank base64-encoded png | ||
* @see https://png-pixel.com/ | ||
*/ | ||
const blankPng = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mN8/x8AAuMB8DtXNJsAAAAASUVORK5CYII='; | ||
|
||
/** | ||
* dev.to logo | ||
* @see https://dev.to/brand | ||
*/ | ||
const devLogoSvg = html`<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 32 448 448"> | ||
<g stroke-width="0"> | ||
<rect x="0" y="32" width="400" height="400" rx="49" fill="#ffffff" strokewidth="0"/> | ||
</g><path fill="#000000" d="M120.12 208.29c-3.88-2.9-7.77-4.35-11.65-4.35H91.03v104.47h17.45c3.88 0 7.77-1.45 11.65-4.35 3.88-2.9 5.82-7.25 5.82-13.06v-69.65c-.01-5.8-1.96-10.16-5.83-13.06zM404.1 32H43.9C19.7 32 .06 51.59 0 75.8v360.4C.06 460.41 19.7 480 43.9 480h360.2c24.21 0 43.84-19.59 43.9-43.8V75.8c-.06-24.21-19.7-43.8-43.9-43.8zM154.2 291.19c0 18.81-11.61 47.31-48.36 47.25h-46.4V172.98h47.38c35.44 0 47.36 28.46 47.37 47.28l.01 70.93zm100.68-88.66H201.6v38.42h32.57v29.57H201.6v38.41h53.29v29.57h-62.18c-11.16.29-20.44-8.53-20.72-19.69V193.7c-.27-11.15 8.56-20.41 19.71-20.69h63.19l-.01 29.52zm103.64 115.29c-13.2 30.75-36.85 24.63-47.44 0l-38.53-144.8h32.57l29.71 113.72 29.57-113.72h32.58l-38.46 144.8z"></path></svg>`; | ||
|
||
/** | ||
* Forem icon for a cake (used with "Joined" date) | ||
* @see https://github.com/forem/forem/blob/main/app/assets/images/cake.svg?short_path=e3c7d41 | ||
*/ | ||
const joinedSvg = html`<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> | ||
<path d="M8 6v3.999h3V6h2v3.999h3V6h2v3.999L19 10a3 3 0 012.995 2.824L22 13v1c0 1.014-.377 1.94-.999 2.645L21 21a1 1 0 01-1 1H4a1 1 0 01-1-1v-4.36a4.025 4.025 0 01-.972-2.182l-.022-.253L2 14v-1a3 3 0 012.824-2.995L5 10l1-.001V6h2zm11 6H5a1 1 0 00-.993.883L4 13v.971l.003.147a2 2 0 003.303 1.4c.363-.312.602-.744.674-1.218l.015-.153.005-.176c.036-1.248 1.827-1.293 1.989-.134l.01.134.004.147a2 2 0 003.992.031l.012-.282c.124-1.156 1.862-1.156 1.986 0l.012.282a2 2 0 003.99 0L20 14v-1a1 1 0 00-.883-.993L19 12zM7 1c1.32.871 1.663 2.088 1.449 2.888a1.5 1.5 0 11-2.898-.776C5.85 2.002 7 2.5 7 1zm5 0c1.32.871 1.663 2.088 1.449 2.888a1.5 1.5 0 01-2.898-.776C10.85 2.002 12 2.5 12 1zm5 0c1.32.871 1.663 2.088 1.449 2.888a1.5 1.5 0 01-2.898-.776C15.85 2.002 17 2.5 17 1z"/> | ||
</svg>`; | ||
|
||
/** | ||
* Forem icon for a post | ||
* @see https://github.com/forem/forem/blob/main/app/assets/images/post.svg?short_path=b79fa43 | ||
*/ | ||
const postSvg = html`<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> | ||
<path d="M19 22H5a3 3 0 01-3-3V3a1 1 0 011-1h14a1 1 0 011 1v12h4v4a3 3 0 01-3 3zm-1-5v2a1 1 0 002 0v-2h-2zm-2 3V4H4v15a1 1 0 001 1h11zM6 7h8v2H6V7zm0 4h8v2H6v-2zm0 4h5v2H6v-2z"/> | ||
</svg>`; | ||
|
||
/** | ||
* Content about one post by dev.to (or Forem) user, sourced from the Forem API. | ||
* @see https://developers.forem.com/api/v1#tag/articles/operation/getLatestArticles | ||
* @typedef {Object} ForemPost | ||
* @property {string} title - The title of the post | ||
* @property {string} url - The URL of the post | ||
* @property {string} cover_image - The URL of the post's full-size cover image | ||
*/ | ||
|
||
/** | ||
* Render a link to a post | ||
* @param {ForemPost} post - Content about a post | ||
*/ | ||
const postLink = (post) => html`<a href="${post.url}" part="post"> | ||
<img src="${post.cover_image}" part="post-img" alt="Cover image for post ${post.title}" /> | ||
${post.title}</a>`; | ||
|
||
/** | ||
* Content about a dev.to (or Forem) user, sourced from the Forem API and combined with post data. | ||
* Only the properties used in this component are defined. | ||
* @see https://developers.forem.com/api/v0#tag/users/operation/getUser | ||
* @typedef {Object} ForemUser | ||
* | ||
* @property {string} username - The username of the user | ||
* @property {string} name - The name of the user | ||
* @property {string} summary - The user's bio | ||
* @property {string} joined_at - The date the user joined | ||
* @property {string} profile_image - The URL of the user's profile image | ||
* @property {number} post_count - The number of posts the user has published | ||
*/ | ||
|
||
|
||
/** | ||
* Render a link to a user's profile | ||
* @param {ForemUser} user - Content about a user | ||
*/ | ||
const profileLink = (user) => html` | ||
<address> | ||
<a href="https://dev.to/${user?.username}" part="cta branded">View Profile on dev.to</a> | ||
</address> | ||
`; | ||
|
||
/** | ||
* Render a user's avatar | ||
* @param {ForemUser} user - Content about a user | ||
*/ | ||
const userAvatar = (user) => html`<img src="${user?.profile_image}" part="avatar" alt="Avatar for ${user?.name}" loading="lazy" />`; | ||
|
||
/** | ||
* Render a user's joined date | ||
* @param {ForemUser} user - Content about a user | ||
*/ | ||
const userJoined = (user) => this.user?.joined_at ? html`<p part="mild"> | ||
${joinedSvg} | ||
<span>Joined on | ||
<time datetime="${this.user?.joined}">${this.user?.joined_at}</time> | ||
</span> | ||
</p> | ||
` : ''; | ||
|
||
/** | ||
* dev.to profile component | ||
* @element dev-user | ||
* @cssprop --devto-color | ||
* @prop {string} username - The username of the user | ||
* @prop {ForemUser} user - Content about a user | ||
* @prop {ForemPost} latest_post - Content about a post | ||
*/ | ||
export class DevToProfile extends LitElement { | ||
static properties = { | ||
user: { type: Object }, | ||
username: { type: String }, | ||
latest_post: { type: Object }, | ||
skipFetch: { type: Boolean }, | ||
}; | ||
static styles = css` | ||
${unsafeCSS(styles)} | ||
`; | ||
|
||
async firstUpdated() { | ||
if (!this.username && this.user?.username) { | ||
this.username = this.user.username; | ||
} | ||
if (this.skipFetch) { | ||
if (!this.username) { | ||
this._generateError( | ||
'A username is required to skip fetching data', | ||
'UI requires a name and username at minimum', | ||
) | ||
return; | ||
} | ||
} else { | ||
if (this.username) { | ||
await this._generateUser(this.username); | ||
} else { | ||
await this._generateUser(null, this.user?.id); | ||
} | ||
} | ||
await this._cleanUserData(); | ||
} | ||
|
||
/** | ||
* Format a date for machine-readability | ||
* @param {string} dt | ||
* @returns {string} - the machine-readable value of the date | ||
*/ | ||
_formatDate(dt) { | ||
const x = new Date(dt); | ||
const year = x.getFullYear() | ||
const month = String(x.getMonth() + 1).padStart(2, '0') | ||
const day = String(x.getDate()).padStart(2, '0') | ||
|
||
return `${year}-${month}-${day}` | ||
} | ||
|
||
async _fetchPosts(username) { | ||
const articles = await fetch(`https://dev.to/api/articles/latest?per_page=1000&username=${username?.toLowerCase()}`); | ||
const articlesJson = await articles.json(); | ||
this.user.post_count = this.user.post_count || articlesJson.length; | ||
if (articlesJson.length && !this.latest_post) { | ||
this.latest_post = articlesJson[0]; | ||
} | ||
} | ||
|
||
async _fetchUserResponse(username, id) { | ||
if (!username && id) { | ||
return await fetch(`https://dev.to/api/users/${id}`); | ||
} | ||
return await fetch(`https://dev.to/api/users/by_username?url=${username?.toLowerCase()}`); | ||
} | ||
|
||
async _generateError(msg, status) { | ||
this.user = { | ||
name: msg, | ||
status, | ||
} | ||
} | ||
|
||
async _generateUser(username, id) { | ||
const response = await this._fetchUserResponse(username, id); | ||
const jsonResponse = await response.json(); | ||
if (jsonResponse.error) { | ||
this._generateError( | ||
`User ${username || id} ${jsonResponse.error}`, | ||
jsonResponse.error, | ||
) | ||
return; | ||
} | ||
this.user = { | ||
...jsonResponse, | ||
...this.user, | ||
} | ||
if (typeof this.user.post_count !== 'number' || (this.user.post_count > 0 && !this.latest_post)) { | ||
await this._fetchPosts(this.user.username); | ||
} | ||
console.log(this.user) | ||
console.log(this.latest_post) | ||
} | ||
|
||
/** | ||
* Clean up data to conform to the HTML-expected content model | ||
*/ | ||
async _cleanUserData() { | ||
this.user.profile_image = this.user.profile_image || blankPng; | ||
this.user.name = this.user.name || `@${this.user.username}`; | ||
if (this.user.joined_at) { | ||
this.user.joined = this.user.joined || this._formatDate(this.user.joined_at); | ||
} | ||
if (this.user.post_count && Number(this.user.post_count) !== NaN) { | ||
this.user.post_count = Number(this.user.post_count); | ||
} else { | ||
delete this.user?.post_count; | ||
} | ||
this.latest_post.cover_image = this.latest_post.cover_image || blankPng; | ||
} | ||
|
||
render() { | ||
if (this.user?.status) { | ||
return html` | ||
<section part="container"> | ||
<header> | ||
<div part="logo">${devLogoSvg}</div> | ||
<p role="heading"> | ||
<span part="name bold">${this.user?.name}</span> | ||
</p> | ||
</header> | ||
</section> | ||
`; | ||
} | ||
|
||
return when(this.user?.username, () => html` | ||
<section part="container"> | ||
<header> | ||
<div part="logo">${devLogoSvg}</div> | ||
<p role="heading"> | ||
<a href="https://dev.to/${this.user?.username}"> | ||
<span part="avatar-container"> | ||
${userAvatar(this.user)} | ||
</span> | ||
<span part="name bold">${this.user?.name}</span> | ||
</a> | ||
</p> | ||
</header> | ||
<div part="main"> | ||
${when(this.user?.summary, () => html`<p>${this.user?.summary}</p>`)} | ||
${when(this.user?.joined_at, () => html`<p part="mild"> | ||
${joinedSvg} | ||
<span>Joined on | ||
<time datetime="${this.user?.joined}">${this.user?.joined_at}</time> | ||
</span> | ||
</p>`)} | ||
${when(this.user?.post_count > 0, () => html` | ||
<p part="mild"> | ||
${postSvg} | ||
<span>${this.user?.post_count} posts published</span> | ||
</p> | ||
`)} | ||
${when(this.latest_post, () => html` | ||
<dl> | ||
<dt part="mild">Latest post</dt> | ||
<dd>${postLink(this.latest_post)}</dd> | ||
</dl> | ||
`)} | ||
</div> | ||
<footer> | ||
<p>${profileLink(this.user)}</p> | ||
</footer> | ||
</section> | ||
`); | ||
} | ||
} | ||
|
||
customElements.define('dev-user', DevToProfile); |
Oops, something went wrong.