Browse Source

work in progress to get apollo working

Tomi Cvetic 5 năm trước cách đây
mục cha
commit
9e775fceda

+ 1 - 1
backend/.graphqlconfig.yml

@@ -5,6 +5,6 @@ projects:
       endpoints:
         default: "http://localhost:4477"
   prisma:
-    schemaPath: "../database/generated/prisma.graphql"
+    schemaPath: "./database/generated/prisma.graphql"
     extensions:
       prisma: prisma.yml

+ 11 - 0
backend/Dockerfile

@@ -0,0 +1,11 @@
+FROM node:alpine
+
+WORKDIR /app
+
+ENV PATH /app/node_modules/.bin:$PATH
+
+COPY package.json /app/package.json
+
+RUN npm install --silent
+
+CMD ["npm", "run", "dev"]

+ 0 - 0
database/generated/prisma-client/index.d.ts → backend/database/generated/prisma-client/index.d.ts


+ 0 - 0
database/generated/prisma-client/index.js → backend/database/generated/prisma-client/index.js


+ 0 - 0
database/generated/prisma-client/prisma-schema.js → backend/database/generated/prisma-client/prisma-schema.js


+ 1 - 1
database/generated/prisma.graphql → backend/database/generated/prisma.graphql

@@ -1,5 +1,5 @@
 # source: http://localhost:8846
-# timestamp: Tue Nov 05 2019 17:50:27 GMT+0100 (Central European Standard Time)
+# timestamp: Tue Nov 05 2019 20:22:41 GMT+0100 (Central European Standard Time)
 
 type AggregateBlock {
   count: Int!

+ 26 - 27
backend/index.js

@@ -5,27 +5,26 @@
  * Configure CORS for use with localhost.
  */
 
-require("dotenv").config();
-const { GraphQLServer } = require("graphql-yoga");
-const cookieParser = require("cookie-parser");
-const bodyParser = require("body-parser");
-const cors = require("cors");
-const express = require("express");
-//const { merge } = require('lodash')
-//const { db, populateUser } = require('./src/db')
-//const { authenticate } = require('./src/authenticate')
+require('dotenv').config()
+const { GraphQLServer } = require('graphql-yoga')
+const cookieParser = require('cookie-parser')
+const bodyParser = require('body-parser')
+const cors = require('cors')
+const express = require('express')
+const { merge } = require('lodash')
+const { db, populateUser } = require('./src/db')
+// const { authenticate } = require('./src/authenticate')
 
-//const prismaResolvers = require('./src/resolvers')
+const prismaResolvers = require('./src/resolvers')
 
-//const typeDefs = ['./schema.graphql', system.typeDefs, interfaces.typeDefs]
+// const typeDefs = ['./schema.graphql', system.typeDefs, interfaces.typeDefs]
 
-/*const resolvers = merge(
-    system.resolvers,
-    interfaces.resolvers,
-    prismaResolvers.resolvers
-  )*/
-const resolvers = {};
-const typeDefs = [];
+const resolvers = merge(
+  // system.resolvers,
+  // interfaces.resolvers,
+  prismaResolvers.resolvers
+)
+const typeDefs = ['./schema.graphql']
 
 const server = new GraphQLServer({
   typeDefs,
@@ -35,17 +34,17 @@ const server = new GraphQLServer({
     db,
     debug: true
   })
-});
+})
 
-server.express.use(cookieParser());
-server.express.use(bodyParser.json());
-server.express.use(quickMiddleware);
-server.express.use(authenticate);
-server.express.use(populateUser);
+server.express.use(cookieParser())
+server.express.use(bodyParser.json())
+// server.express.use(quickMiddleware)
+// server.express.use(authenticate)
+server.express.use(populateUser)
 server.express.use(
   cors({ origin: process.env.FRONTEND_URL, credentials: true })
-);
-server.express.use("/static", express.static("static"));
+)
+server.express.use('/static', express.static('static'))
 
 server.start(
   {
@@ -55,4 +54,4 @@ server.start(
     }
   },
   server => console.log(`Server is running on http://localhost:${server.port}`)
-);
+)

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 155 - 27
backend/package-lock.json


+ 1 - 1
backend/prisma.yml

@@ -6,4 +6,4 @@ hooks:
     - graphql get-schema -p prisma
 generate:
   - generator: javascript-client
-    output: ../database/generated/prisma-client
+    output: ./database/generated/prisma-client

+ 12 - 0
backend/schema.graphql

@@ -0,0 +1,12 @@
+# import * from '../database/generated/prisma.graphql'
+
+type Query {
+    users(where: UserWhereInput, orderBy: UserOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [User]!
+    me: User!
+}
+
+type Mutation {
+    createUser(name: String!, email: String!, abbreviation: String!, password: String!): User!
+    userLogin(email: String!, password: String!): User!
+    userLogout: String!
+}

+ 27 - 0
backend/src/db.js

@@ -0,0 +1,27 @@
+/*
+ * Using prisma-binding from
+ * https://github.com/prisma/prisma-binding
+ */
+
+const { Prisma } = require('prisma-binding')
+const {
+  typeDefs
+} = require('../database/generated/prisma-client/prisma-schema')
+
+const db = new Prisma({
+  typeDefs,
+  endpoint: process.env.PRISMA_ENDPOINT,
+  secret: process.env.PRISMA_SECRET
+})
+
+const populateUser = async (req, res, next) => {
+  if (!req.userId) return next()
+  const user = await db.query.user(
+    { where: { id: req.userId } },
+    '{id, email, name}'
+  )
+  req.user = user
+  next()
+}
+
+module.exports = { db, populateUser }

+ 67 - 0
backend/src/resolvers.js

@@ -0,0 +1,67 @@
+const { forwardTo } = require('prisma-binding')
+const bcrypt = require('bcryptjs')
+const jwt = require('jsonwebtoken')
+
+const Query = {
+  users: forwardTo('db'),
+  me: (parent, args, context, info) => {
+    if (!context.request.userId) throw new Error('Not logged in.')
+    return context.db.query.user(
+      { where: { id: context.request.userId } },
+      info
+    )
+  }
+}
+
+const Mutation = {
+  createUser: async (parent, args, context, info) => {
+    const email = args.email.toLowerCase()
+    const password = await bcrypt.hash(args.password, 10)
+    console.log(email, password)
+    const user = await context.db.mutation.createUser(
+      {
+        data: {
+          ...args,
+          email,
+          password
+        }
+      },
+      info
+    )
+    const token = jwt.sign({ userId: user.id }, process.env.APP_SECRET)
+    context.response.cookie('token', token, {
+      httpOnly: true,
+      maxAge: 7 * 24 * 3600 * 1000
+    })
+    return user
+  },
+  userLogin: async (parent, args, context, info) => {
+    const { email, password } = args
+    const user = await context.db.query.user({ where: { email } })
+    if (!user) throw new Error('User not found')
+    const valid = await bcrypt.compare(password, user.password)
+    if (!valid) throw new Error('Invalid password')
+    const token = jwt.sign({ userId: user.id }, process.env.APP_SECRET)
+    context.response.cookie(
+      'token',
+      token,
+      {
+        httpOnly: true,
+        maxAge: 7 * 24 * 3600 * 1000
+      },
+      info
+    )
+    return user
+  },
+  userLogout: async (parent, args, context, info) => {
+    context.response.clearCookie('token')
+    return 'Logged out.'
+  }
+}
+
+const resolvers = {
+  Query,
+  Mutation
+}
+
+module.exports = { resolvers }

+ 12 - 0
docker-compose.yml

@@ -13,6 +13,18 @@ services:
     environment:
       - NODE_ENV=development
 
+  backend:
+    container_name: backend
+    build:
+      context: backend
+    volumes:
+      - "./backend:/app"
+      - "/app/node_modules"
+    ports:
+      - "127.0.0.1:8801:4000"
+    environment:
+      - NODE_ENV=development
+
   prisma:
     image: prismagraphql/prisma:1.34.10
     restart: always

+ 7 - 0
frontend/components/footer.js

@@ -0,0 +1,7 @@
+const Footer = props => (
+  <footer>
+    <p>Footer</p>
+  </footer>
+)
+
+export default Footer

+ 10 - 0
frontend/components/header.js

@@ -0,0 +1,10 @@
+import Logo from './logo'
+
+const Header = props => (
+  <header>
+    <Logo />
+    <p>Header</p>
+  </header>
+)
+
+export default Header

+ 14 - 0
frontend/components/meta.js

@@ -0,0 +1,14 @@
+import Head from 'next/head'
+
+const Meta = () => (
+  <Head>
+    <meta name='viewport' content='width=device-width, initial-scale=1' />
+    <meta charSet='utf-8' />
+    <link rel='shortcut icon' href='/static/favicon.svg' />
+    <link rel='stylesheet' type='text/css' href='/static/nprogress.css' />
+    <link rel='stylesheet' type='text/css' href='/static/reset.css' />
+    <title>u-fit</title>
+  </Head>
+)
+
+export default Meta

+ 1 - 7
frontend/components/nav.js

@@ -1,13 +1,9 @@
 import React from 'react'
 import Link from 'next/link'
-import Logo from '../components/logo'
 
 const Nav = () => (
   <nav>
     <ul>
-      <li>
-        <Logo />
-      </li>
       <li>
         <Link href='/'>
           <a>Home</a>
@@ -19,9 +15,7 @@ const Nav = () => (
       :global(body) {
         margin: 0;
       }
-      nav {
-        grid-area: header;
-      }
+
       ul {
         display: flex;
         justify-content: space-between;

+ 110 - 0
frontend/components/page.js

@@ -0,0 +1,110 @@
+import React from 'react'
+import styled, { ThemeProvider, createGlobalStyle } from 'styled-components'
+import Header from './header'
+import Meta from './meta'
+
+const theme = {
+  lightred: '#f0b0b0',
+  red: '#f03535',
+  black: '#393939',
+  grey: '#7f8c8d',
+  lightgrey: '#95a5a5',
+  lighterblue: '#d6e4f0',
+  lightblue: '#b0d3f0',
+  blue: '#4482c3',
+  darkblue: '#285680',
+  darkerblue: '#204567',
+  offWhite: '#EDEDED',
+  maxWidth: '1000px',
+  bs: '0 12px 24px 0 rgba(0,0,0,0.09)',
+  bsSmall: '0 5px 10px 0 rgba(0,0,0,0.19)'
+}
+
+const StyledPage = styled.div`
+  background: white;
+  color: ${props => props.theme.black};
+`
+
+const Inner = styled.div`
+  max-width: ${props => props.theme.maxWidth};
+  margin: 0 auto;
+  padding: 2rem;
+`
+
+const GlobalStyle = createGlobalStyle`
+  @font-face {
+    font-family: 'roboto';
+    src: url('/static/Roboto-Thin.woff2');
+  }
+  @font-face {
+    font-family: 'roboto_mono';
+    src: url('/static/RobotoMono-Thin.woff2');
+  }
+  @font-face {
+    font-family: 'roboto_black';
+    src: url('/static/Roboto-Black.woff2');
+  }
+  
+  html {
+    box-sizing: border-box;
+    font-size: 12px;
+  }
+
+  *, *:before, *:after {
+    box-sizing: inherit;
+  }
+
+  body {
+    padding: 0;
+    margin: 0;
+    font-size: 1.5rem;
+    line-height: 2;
+    font-family: 'roboto', sans-serif;
+  }
+
+  h1 {
+    font-family: 'roboto_black';
+  }
+
+  h2, h3, h4, h5, h6 {
+  }
+
+  button {
+    font-family: 'roboto_black';
+    background: ${props => props.theme.darkblue};
+    color: ${props => props.theme.lighterblue};
+    border: 1px solid ${props => props.theme.darkerblue};
+    padding: 0.3em 1.8em;
+    cursor: pointer;
+    box-shadow: ${props => props.theme.bsSmall};
+  }
+
+  input,
+  textarea {
+    font-family: 'roboto';
+    border: 1px solid ${props => props.theme.lightgrey};
+    padding: 6px;
+    margin: 0 8px;
+  }
+
+  pre {
+    font-family: 'roboto_mono';
+  }
+`
+
+class Page extends React.Component {
+  render () {
+    return (
+      <ThemeProvider theme={theme}>
+        <StyledPage>
+          <Meta />
+          <GlobalStyle />
+          <Header />
+          <Inner>{this.props.children}</Inner>
+        </StyledPage>
+      </ThemeProvider>
+    )
+  }
+}
+
+export default Page

+ 1 - 1
frontend/components/sidebar.js

@@ -1,5 +1,5 @@
 const Sidebar = props => (
-  <div>
+  <div id='sidebar'>
     <p>Sidebar</p>
   </div>
 )

+ 2 - 0
frontend/config.js

@@ -0,0 +1,2 @@
+export const endpoint = `http://localhost:4000`
+export const prodEndpoint = `http://localhost:4000`

+ 44 - 0
frontend/lib/withApollo.js

@@ -0,0 +1,44 @@
+/**
+ * Using next-with-apollo
+ * https://github.com/lfades/next-with-apollo
+ *
+ * Changes:
+ * * Reading endpoint and prodEndpoint from a config file
+ * * Setting request to handle credentials.
+ */
+
+import withApollo from 'next-with-apollo'
+import ApolloClient from 'apollo-client'
+import { InMemoryCache } from 'apollo-cache-inmemory'
+import { onError } from 'apollo-link-error'
+import { ApolloLink } from 'apollo-link'
+import { createUploadLink } from 'apollo-upload-client'
+import { endpoint, prodEndpoint } from '../config'
+
+const cache = new InMemoryCache()
+
+function createClient ({ ctx, headers }) {
+  return new ApolloClient({
+    link: ApolloLink.from([
+      onError(({ graphQLErrors, networkError }) => {
+        if (graphQLErrors) {
+          graphQLErrors.map(({ message, locations, path }) =>
+            console.log(
+              `[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`
+            )
+          )
+        }
+        if (networkError) console.log(`[Network error]: ${networkError}`)
+      }),
+      createUploadLink({
+        uri: process.env.NODE_ENV === 'development' ? endpoint : prodEndpoint,
+        headers,
+        credentials: 'include'
+      })
+    ]),
+    fetchOptions: { mode: 'no-cors' },
+    cache
+  })
+}
+
+export default withApollo(createClient)

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 768 - 0
frontend/package-lock.json


+ 17 - 1
frontend/package.json

@@ -8,8 +8,24 @@
     "start": "next start"
   },
   "dependencies": {
+    "apollo-boost": "^0.4.4",
+    "apollo-cache-inmemory": "^1.6.3",
+    "apollo-client": "^2.6.4",
+    "apollo-link": "^1.2.13",
+    "apollo-link-error": "^1.1.12",
+    "apollo-link-http": "^1.5.16",
+    "dotenv": "^8.2.0",
+    "graphql": "^14.5.8",
+    "graphql-tag": "^2.10.1",
     "next": "9.1.2",
+    "next-link": "^2.0.0",
+    "next-with-apollo": "^4.3.0",
+    "nprogress": "^0.2.0",
     "react": "16.11.0",
-    "react-dom": "16.11.0"
+    "react-adopt": "^0.6.0",
+    "react-apollo": "^3.1.3",
+    "react-dom": "16.11.0",
+    "standard": "^14.3.1",
+    "styled-components": "^4.4.1"
   }
 }

+ 52 - 0
frontend/pages/_app.js

@@ -0,0 +1,52 @@
+import App, { Container } from 'next/app'
+import Page from '../components/page'
+import { ApolloProvider } from 'react-apollo'
+import withApollo from '../lib/withApollo'
+
+/**
+ * Next.js uses the `App` component to initialize pages. See:
+ * https://github.com/zeit/next.js/#custom-app
+ *
+ * Example how to use it to style child components:
+ * https://github.com/zeit/next.js/blob/canary/examples/with-app-layout/pages/_app.js
+ *
+ * Instead of the Layout component, we use the Page component here,
+ * where we add a styled-components theme provider, and Next.js headers and metas.
+ * - Using Page with layout information
+ *
+ * Using next-with-apollo:
+ * https://github.com/lfades/next-with-apollo
+ * - Wrapping MyApp in withApollo HOC
+ */
+
+class MyApp extends App {
+  static async getInitialProps ({ Component, ctx }) {
+    let pageProps = {}
+
+    if (Component.getInitialProps) {
+      pageProps = await Component.getInitialProps(ctx)
+    }
+
+    // Add the query object to the pageProps
+    // https://github.com/wesbos/Advanced-React/blob/master/finished-application/frontend/pages/_app.js
+    pageProps.query = ctx.query
+
+    return { pageProps }
+  }
+
+  render () {
+    const { Component, apollo, pageProps } = this.props
+
+    return (
+      <Container>
+        <ApolloProvider client={apollo}>
+          <Page>
+            <Component {...pageProps} />
+          </Page>
+        </ApolloProvider>
+      </Container>
+    )
+  }
+}
+console.log('read _app.js')
+export default withApollo(MyApp)

+ 43 - 0
frontend/pages/_document.js

@@ -0,0 +1,43 @@
+/**
+ * _document is only rendered on the server
+ * https://github.com/zeit/next.js/#custom-document
+ *
+ * We're using styled-components, so here is the right place
+ * to place the styles.
+ * https://www.styled-components.com/docs/advanced#server-side-rendering
+ *
+ * Configure babel for server-side rendering with styled-components
+ * https://dev.to/aprietof/nextjs--styled-components-the-really-simple-guide----101c
+ */
+
+import Document from 'next/document'
+import { ServerStyleSheet } from 'styled-components'
+
+class MyDocument extends Document {
+  static async getInitialProps (ctx) {
+    const sheet = new ServerStyleSheet()
+    const originalRenderPage = ctx.renderPage
+
+    try {
+      ctx.renderPage = () =>
+        originalRenderPage({
+          enhanceApp: App => props => sheet.collectStyles(<App {...props} />)
+        })
+
+      const initialProps = await Document.getInitialProps(ctx)
+      return {
+        ...initialProps,
+        styles: (
+          <>
+            {initialProps.styles}
+            {sheet.getStyleElement()}
+          </>
+        )
+      }
+    } finally {
+      sheet.seal()
+    }
+  }
+}
+
+export default MyDocument

+ 7 - 29
frontend/pages/index.js

@@ -1,7 +1,9 @@
 import React from 'react'
 import Head from 'next/head'
+import Header from '../components/header'
 import Nav from '../components/nav'
 import Sidebar from '../components/sidebar'
+import Footer from '../components/footer'
 import Poll from '../components/poll'
 import Training from '../components/training'
 import { TrainingArchive } from '../components/training'
@@ -11,7 +13,7 @@ import { PeopleList } from '../components/people'
 import data from '../initial-data.js'
 
 const Home = () => (
-  <div>
+  <div id='app'>
     <Head>
       <title>Home</title>
       <link
@@ -21,40 +23,16 @@ const Home = () => (
       <link rel='icon' href='/favicon.ico' />
     </Head>
 
+    <Header />
     <Nav />
-    <Sidebar />
-    <div id='content'>
+    <main>
       <Poll />
       <TrainingArchive trainings={data.trainings} />
       <ExerciseList exercises={data.exercises} />
       <PeopleList people={data.people} />
       <Training training={data.trainings[data.trainings.length - 1]} />
-    </div>
-
-    <style jsx>{`
-      :global(html) {
-        font-family: 'Noto Sans', sans-serif;
-        box-sizing: border-box;
-      }
-
-      :global(*),
-      :global(*:before),
-      :global(*:after) {
-        box-sizing: inherit;
-      }
-
-      :global(body) {
-        display: grid;
-        grid-template-columns: 250px 1fr;
-        grid-template-areas:
-          'header header'
-          'sidebar content';
-      }
-
-      #content {
-        grid-area: content;
-      }
-    `}</style>
+    </main>
+    <Footer />
   </div>
 )
 

BIN
frontend/public/Roboto-Black.woff2


BIN
frontend/public/Roboto-Medium.woff2


BIN
frontend/public/Roboto-Thin.woff2


BIN
frontend/public/RobotoMono-Medium.woff2


BIN
frontend/public/RobotoMono-Thin.woff2


+ 73 - 0
frontend/public/nprogress.css

@@ -0,0 +1,73 @@
+    /* Make clicks pass-through */
+    #nprogress {
+        pointer-events: none;
+      }
+  
+      #nprogress .bar {
+        background: red;
+        position: fixed;
+        z-index: 1031;
+        top: 0;
+        left: 0;
+  
+        width: 100%;
+        height: 5px;
+      }
+  
+      /* Fancy blur effect */
+      #nprogress .peg {
+        display: block;
+        position: absolute;
+        right: 0px;
+        width: 100px;
+        height: 100%;
+        box-shadow: 0 0 10px red, 0 0 5px red;
+        opacity: 1.0;
+  
+        -webkit-transform: rotate(3deg) translate(0px, -4px);
+            -ms-transform: rotate(3deg) translate(0px, -4px);
+                transform: rotate(3deg) translate(0px, -4px);
+      }
+  
+      /* Remove these to get rid of the spinner */
+      #nprogress .spinner {
+        display: block;
+        position: fixed;
+        z-index: 1031;
+        top: 15px;
+        right: 15px;
+      }
+  
+      #nprogress .spinner-icon {
+        width: 18px;
+        height: 18px;
+        box-sizing: border-box;
+  
+        border: solid 2px transparent;
+        border-top-color: red;
+        border-left-color: red;
+        border-radius: 50%;
+  
+        -webkit-animation: nprogress-spinner 400ms linear infinite;
+                animation: nprogress-spinner 400ms linear infinite;
+      }
+  
+      .nprogress-custom-parent {
+        overflow: hidden;
+        position: relative;
+      }
+  
+      .nprogress-custom-parent #nprogress .spinner,
+      .nprogress-custom-parent #nprogress .bar {
+        position: absolute;
+      }
+  
+      @-webkit-keyframes nprogress-spinner {
+        0%   { -webkit-transform: rotate(0deg); }
+        100% { -webkit-transform: rotate(360deg); }
+      }
+      @keyframes nprogress-spinner {
+        0%   { transform: rotate(0deg); }
+        100% { transform: rotate(360deg); }
+      }
+  

+ 85 - 0
frontend/public/reset.css

@@ -0,0 +1,85 @@
+html, body, div, span, applet, object, iframe,
+h1, h2, h3, h4, h5, h6, p, blockquote, pre,
+a, abbr, acronym, address, big, cite, code,
+del, dfn, em, img, ins, kbd, q, s, samp,
+small, strike, strong, sub, sup, tt, var,
+b, u, i, center,
+dl, dt, dd, ol, ul, li,
+fieldset, form, label, legend,
+table, caption, tbody, tfoot, thead, tr, th, td,
+article, aside, canvas, details, embed,
+figure, figcaption, footer, header, hgroup,
+main, menu, nav, output, ruby, section, summary,
+time, mark, audio, video {
+  margin: 0;
+  padding: 0;
+  border: 0;
+  font-size: 100%;
+  font: inherit;
+  vertical-align: baseline;
+}
+/* HTML5 display-role reset for older browsers */
+article, aside, details, figcaption, figure,
+footer, header, hgroup, main, menu, nav, section {
+  display: block;
+}
+/* HTML5 hidden-attribute fix for newer browsers */
+*[hidden] {
+  display: none;
+}
+body {
+  line-height: 1;
+}
+ol, ul {
+  list-style: none;
+}
+blockquote, q {
+  quotes: none;
+}
+blockquote:before, blockquote:after,
+q:before, q:after {
+  content: '';
+  content: none;
+}
+table {
+  border-collapse: collapse;
+  border-spacing: 0;
+}
+/* http://www.paulirish.com/2012/box-sizing-border-box-ftw/ (2015/04/28)*/
+html {
+  box-sizing: border-box;
+}
+*, *:before, *:after {
+  box-sizing: inherit;
+}
+/* Additional resets */
+a {
+  text-decoration: none;
+  color: inherit;
+}
+button {
+  border: none;
+  margin: 0;
+  padding: 0;
+  width: auto;
+  overflow: visible;
+  background: transparent;
+  color: inherit;
+  font: inherit;
+  text-align: inherit;
+  outline: none;
+  line-height: inherit;
+  -webkit-appearance: none;
+}
+/* Fix antialiasing */
+*, *:before, *:after {
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+/* Disable user select on everything but texts */
+*, *:before, *:after {
+  user-select: none;
+}
+p, h1, h2, h3, h4, h5, h6, blockquote, pre, ul, ol, li, table, tr, th, td {
+  user-select: all;
+}

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác