Skip to content

Commit

Permalink
✨ initial creation of dev.to profile and random-fox components (#2)
Browse files Browse the repository at this point in the history
  • Loading branch information
scottnath authored Jul 20, 2023
1 parent 20b0a36 commit e0b7361
Show file tree
Hide file tree
Showing 11 changed files with 1,271 additions and 64 deletions.
631 changes: 579 additions & 52 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions workspaces/components/.storybook/preview.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { setCustomElementsManifest } from '@storybook/web-components';
import customElements from '../src/custom-elements.json';

setCustomElementsManifest(customElements);
/** @type { import('@storybook/web-components').Preview } */
const preview = {
parameters: {
Expand Down
24 changes: 24 additions & 0 deletions workspaces/components/src/devto/README.md
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
29 changes: 29 additions & 0 deletions workspaces/components/src/devto/devto.shared-spec.js
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,
}
61 changes: 61 additions & 0 deletions workspaces/components/src/devto/devto.stories.js
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',
},
}
272 changes: 272 additions & 0 deletions workspaces/components/src/devto/index.js
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);
Loading

0 comments on commit e0b7361

Please sign in to comment.